Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7abf844ea | |||
| 00447e4dde | |||
| 25f8468696 | |||
| a29a82b74c | |||
| fd616409dd | |||
| 2543e6cbf1 | |||
| b8067c2d33 | |||
| 510cf6126c | |||
| 90fb97a4e7 | |||
| 77adeb3a6b | |||
| 4e592173a6 | |||
| 0cacb5c94a | |||
| 6c96409969 | |||
| 06d51c1ee9 | |||
| 2ad3d1df38 | |||
| 83c7770877 | |||
| bad14fbe7f | |||
| 578e8f55bd | |||
| ae3e432b02 | |||
| 7c14390400 | |||
| 89885c1583 | |||
| 5a27630ccf | |||
| ed91260468 | |||
| c13aeb3a74 |
+50
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
julia_version = "1.12.6"
|
||||
manifest_format = "2.0"
|
||||
project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c"
|
||||
project_hash = "aa163e2bf572632825162936e107be18384fd40f"
|
||||
|
||||
[[deps.Accessors]]
|
||||
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
|
||||
@@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6"
|
||||
uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
|
||||
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 = ["Sockets", "UUIDs"]
|
||||
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
|
||||
@@ -58,6 +64,12 @@ version = "1.11.0"
|
||||
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
|
||||
version = "1.11.0"
|
||||
|
||||
[[deps.BitIntegers]]
|
||||
deps = ["Random"]
|
||||
git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46"
|
||||
uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
|
||||
version = "0.3.7"
|
||||
|
||||
[[deps.BufferedStreams]]
|
||||
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
|
||||
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
|
||||
@@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a"
|
||||
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
|
||||
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 = ["TranscodingStreams", "Zlib_jll"]
|
||||
git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
|
||||
uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
|
||||
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]]
|
||||
git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
|
||||
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
|
||||
@@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"]
|
||||
[deps.CompositionsBase.extensions]
|
||||
CompositionsBaseInverseFunctionsExt = "InverseFunctions"
|
||||
|
||||
[[deps.ConcurrentUtilities]]
|
||||
deps = ["Serialization", "Sockets"]
|
||||
git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738"
|
||||
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
|
||||
version = "2.6.0"
|
||||
|
||||
[[deps.ConstructionBase]]
|
||||
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
|
||||
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
|
||||
@@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
|
||||
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
|
||||
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]]
|
||||
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
|
||||
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
|
||||
@@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
|
||||
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
|
||||
version = "0.4.7"
|
||||
|
||||
[[deps.StringViews]]
|
||||
git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8"
|
||||
uuid = "354b36f9-a18e-4713-926e-db85100087ba"
|
||||
version = "1.3.7"
|
||||
|
||||
[[deps.StructTypes]]
|
||||
deps = ["Dates", "UUIDs"]
|
||||
git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8"
|
||||
@@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026"
|
||||
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
|
||||
version = "1.0.21+0"
|
||||
|
||||
[[deps.msghandler]]
|
||||
deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"]
|
||||
git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f"
|
||||
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 = ["Artifacts", "Libdl"]
|
||||
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
|
||||
|
||||
@@ -22,6 +22,7 @@ SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
|
||||
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
|
||||
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
|
||||
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
||||
msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10"
|
||||
|
||||
[compat]
|
||||
Base64 = "1.11.0"
|
||||
@@ -33,3 +34,4 @@ JSON = "1.6.1"
|
||||
LLMMCTS = "0.1.5"
|
||||
NATS = "0.1.0"
|
||||
SQLLLM = "0.2.8"
|
||||
msghandler = "1.2.1"
|
||||
|
||||
@@ -6,7 +6,7 @@ Julia framework for building agents with tool use.
|
||||
|
||||
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
|
||||
2. Create a `yiemAgent` with `loadTools("src/tools")`
|
||||
3. Call `run_agent(agent, "message")` then `take_response(agent)`
|
||||
3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -16,7 +16,7 @@ src/
|
||||
├── type.jl # Core types (messages, tools, agent state)
|
||||
├── utils.jl # Message formatting, validation
|
||||
├── agentCore.jl # Agent loop, tool execution pipeline
|
||||
├── api.jl # Public API (run_agent, take_response, etc.)
|
||||
├── api.jl # Public API (runAgent, takeResponse, etc.)
|
||||
└── tools/
|
||||
├── registry.jl # Tool registry (loadTools, registerTool, listTools)
|
||||
├── getWeather.jl # Weather lookup tool
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,5 @@
|
||||
# ── executeToolCalls() Julia pseudo code ──────────────────────────
|
||||
# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit
|
||||
check my understand:
|
||||
1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and
|
||||
it will be the latest message in agent._state.messages. then _agentLoop() can pick it as
|
||||
the output to outputChannel
|
||||
2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall
|
||||
@@ -0,0 +1,8 @@
|
||||
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?
|
||||
|
||||
|
||||
+13
-2
@@ -1,7 +1,6 @@
|
||||
module YiemAgent
|
||||
|
||||
# export agent
|
||||
|
||||
export register_all_tools
|
||||
|
||||
""" Order by dependencies of each file. The 1st included file must not depend on any other
|
||||
files and each file can only depend on the file included before it.
|
||||
@@ -13,9 +12,21 @@ module YiemAgent
|
||||
include("utils.jl")
|
||||
using .utils
|
||||
|
||||
include("tools/getWeather.jl")
|
||||
include("tools/getTime.jl")
|
||||
include("tools/writeTool.jl")
|
||||
|
||||
include("toolRegistry.jl")
|
||||
using .toolRegistry
|
||||
|
||||
function register_all_tools(store::toolRegistry.toolStore)
|
||||
registerTool(store, getWeatherTool())
|
||||
registerTool(store, getTimeTool())
|
||||
registerTool(store, writeToolTool())
|
||||
registerTool(store, listTool(store))
|
||||
return store.tools
|
||||
end
|
||||
|
||||
# include("llmfunction.jl")
|
||||
# using .llmfunction
|
||||
|
||||
|
||||
+735
-336
File diff suppressed because it is too large
Load Diff
+15
-16
@@ -5,12 +5,11 @@ export prompt
|
||||
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
|
||||
DataFrames
|
||||
using GeneralUtils
|
||||
using ..type, ..utils
|
||||
using ..type, ..utils, ..agentCore, ..toolRegistry
|
||||
|
||||
# ---------------------------------------------- 100 --------------------------------------------- #
|
||||
|
||||
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
# Notes
|
||||
- Use `take_response(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 `takeResponse(agent)` to receive the agent's response after sending a message.
|
||||
- Use `followUp(agent, msg)` to send messages while the agent is still processing.
|
||||
|
||||
# Examples
|
||||
```jldoctest
|
||||
julia> run_agent(agent, "Hello!")
|
||||
julia> runAgent(agent, "Hello!")
|
||||
yiemAgent(...)
|
||||
```
|
||||
"""
|
||||
function run_agent(agent::yiemAgent, msg)
|
||||
function runAgent(agent::yiemAgent, msg)
|
||||
put!(agent.inputChannel, msg)
|
||||
return agent
|
||||
end
|
||||
@@ -51,15 +50,15 @@ Blocks until the agent sends a response.
|
||||
- An `assistantMessage` instance representing the agent's response
|
||||
|
||||
# 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
|
||||
```jldoctest
|
||||
julia> response = take_response(agent)
|
||||
julia> response = takeResponse(agent)
|
||||
assistantMessage(...)
|
||||
```
|
||||
"""
|
||||
function take_response(agent::yiemAgent)
|
||||
function takeResponse(agent::yiemAgent)
|
||||
return take!(agent.outputChannel)
|
||||
end
|
||||
|
||||
@@ -77,17 +76,17 @@ and before any tool call results are sent.
|
||||
- The same `agent` instance for chaining
|
||||
|
||||
# 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.
|
||||
- Follow-up messages are buffered in a separate channel (capacity 32 by default).
|
||||
|
||||
# Examples
|
||||
```jldoctest
|
||||
julia> follow_up(agent, "Also consider red wines")
|
||||
julia> followUp(agent, "Also consider red wines")
|
||||
yiemAgent(...)
|
||||
```
|
||||
"""
|
||||
function follow_up(agent::yiemAgent, msg)
|
||||
function followUp(agent::yiemAgent, msg)
|
||||
put!(agent.followUpChannel, msg)
|
||||
return agent
|
||||
end
|
||||
@@ -105,19 +104,19 @@ then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`).
|
||||
- `nothing`
|
||||
|
||||
# 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.
|
||||
- If the background task throws a `TaskFailedException`, it is rethrown.
|
||||
|
||||
# Examples
|
||||
```jldoctest
|
||||
julia> stop_agent(agent)
|
||||
julia> stopAgent(agent)
|
||||
```
|
||||
"""
|
||||
function stop_agent(agent::yiemAgent)
|
||||
function stopAgent(agent::yiemAgent)
|
||||
put!(agent.inputChannel, :shutdown)
|
||||
try
|
||||
fetch(agent._agent_loop)
|
||||
fetch(agent._agentLoop)
|
||||
catch e
|
||||
if e isa TaskFailedException
|
||||
rethrow(e)
|
||||
|
||||
+66
-130
@@ -1,6 +1,6 @@
|
||||
module toolRegistry
|
||||
|
||||
export ToolStore, loadTools, registerTool, getTools, clearTools, listTool
|
||||
export toolStore, registerTool, getTools, clearTools, listTool
|
||||
|
||||
using Dates
|
||||
using JSON, DataStructures
|
||||
@@ -9,47 +9,65 @@ using ..type
|
||||
"""
|
||||
Per-agent isolated tool storage.
|
||||
|
||||
Each agent gets its own `ToolStore` so tool registration is independent —
|
||||
Each agent gets its own `toolStore` so tool registration is independent —
|
||||
`registerTool(store, tool)` only affects that agent's tool set.
|
||||
|
||||
# Fields
|
||||
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
|
||||
- `name::String` — identifier for debugging/logs
|
||||
"""
|
||||
struct ToolStore
|
||||
struct toolStore
|
||||
tools::OrderedDict{String, agentTool}
|
||||
name::String
|
||||
end
|
||||
|
||||
"""
|
||||
Create a new isolated tool store.
|
||||
toolStore(; name="default") -> toolStore
|
||||
|
||||
Create a new empty tool store.
|
||||
|
||||
# Keyword Arguments
|
||||
- `name::String`: Identifier for this store (default: "default")
|
||||
- `name::String`: Display name for logging (default: `"default"`)
|
||||
|
||||
# Examples
|
||||
# Example
|
||||
```julia
|
||||
store = ToolStore(name="agent1")
|
||||
tools = loadTools(store, "src/tools")
|
||||
registerTool(store, my_tool)
|
||||
agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store)
|
||||
julia> store = toolStore(name="agent1")
|
||||
toolStore(OrderedDict{String, agentTool}(), "agent1")
|
||||
```
|
||||
"""
|
||||
function ToolStore(; name::String="default")::ToolStore
|
||||
ToolStore(OrderedDict{String, agentTool}(), name)
|
||||
function toolStore(; name::String="default")::toolStore
|
||||
toolStore(OrderedDict{String, agentTool}(), name)
|
||||
end
|
||||
|
||||
"""
|
||||
List tool definition — lets the agent query available tools for collision detection
|
||||
when creating new tools via writeTool.
|
||||
listTool(store::toolStore) -> agentTool
|
||||
|
||||
Return an `agentTool` definition for listing registered tools.
|
||||
|
||||
Each call produces a **new** tool object that captures (closes over)
|
||||
`store`. `register_all_tools` auto-registers one so the LLM can discover tools
|
||||
at runtime.
|
||||
|
||||
# Arguments
|
||||
- `store::ToolStore`: The tool store to list from
|
||||
- `store`: The tool store whose tools will be listed when the tool runs
|
||||
|
||||
Each `ToolStore` gets its own `listTool` instance bound to that store,
|
||||
so each agent sees only its own tools.
|
||||
# Example
|
||||
```julia
|
||||
julia> store = toolStore(name="agent1");
|
||||
|
||||
julia> register_all_tools(store) # auto-registers listTools
|
||||
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
|
||||
[toolRegistry:agent1] Registered tool: listTools
|
||||
|
||||
julia> tools = getTools(store)
|
||||
OrderedDict{String, agentTool} with 4 entries:
|
||||
"getWeather" => agentTool(...)
|
||||
"getTime" => agentTool(...)
|
||||
"writeTool" => agentTool(...)
|
||||
"listTools" => agentTool(...)
|
||||
```
|
||||
"""
|
||||
function listTool(store::ToolStore)::agentTool
|
||||
function listTool(store::toolStore)::agentTool
|
||||
return agentTool(
|
||||
name = "listTools",
|
||||
label = "List Tools",
|
||||
@@ -79,162 +97,80 @@ function listTool(store::ToolStore)::agentTool
|
||||
)
|
||||
end
|
||||
|
||||
# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope
|
||||
|
||||
"""
|
||||
Load all tool modules from a directory into a specific ToolStore.
|
||||
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
|
||||
|
||||
Scans `dir` for `.jl` files. Each file must define a function named
|
||||
`getTool()::agentTool`. Files are sorted alphabetically so tool
|
||||
registration order is deterministic.
|
||||
|
||||
Each `.jl` file is loaded into its own **submodule** so that all functions
|
||||
defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`,
|
||||
and any helper functions) are namespaced and never collide with other tools.
|
||||
Add `tool` to `store`, overwriting any existing tool with the same name.
|
||||
|
||||
# Arguments
|
||||
- `store::ToolStore`: The tool store to register tools into
|
||||
- `dir::String`: Directory path to scan for `.jl` tool files
|
||||
- `store`: Tool store to modify
|
||||
- `tool`: The `agentTool` to register
|
||||
|
||||
# Returns
|
||||
- `OrderedDict{String, agentTool}`: All loaded tools keyed by name
|
||||
- The same `store.tools` dict (modified in place)
|
||||
|
||||
# Errors
|
||||
- Throws `ArgumentError` if a tool file does not define a `getTool` function
|
||||
|
||||
# Examples
|
||||
# Example
|
||||
```julia
|
||||
julia> store = ToolStore(name="agent1")
|
||||
julia> tools = loadTools(store, "src/tools")
|
||||
OrderedDict{String, agentTool} with 3 entries:
|
||||
"getWeather" => agentTool(...)
|
||||
"getTime" => agentTool(...)
|
||||
julia> store = toolStore(name="agent1");
|
||||
|
||||
julia> registerTool(store, listTool(store))
|
||||
[toolRegistry:agent1] Registered tool: listTools
|
||||
OrderedDict{String, agentTool} with 1 entry:
|
||||
"listTools" => agentTool(...)
|
||||
```
|
||||
"""
|
||||
function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool}
|
||||
if !isdir(dir)
|
||||
throw(ArgumentError("Tool directory does not exist: $dir"))
|
||||
end
|
||||
|
||||
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
|
||||
sort!(jl_files)
|
||||
|
||||
for filename in jl_files
|
||||
filepath = joinpath(dir, filename)
|
||||
|
||||
# Derive a unique module name from the filename only (not full path).
|
||||
# e.g. "getWeather.jl" -> "_tool_getWeather"
|
||||
mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => ""))
|
||||
|
||||
# Build the complete module as a string and eval the parsed code.
|
||||
# Julia does not allow `module ... end` inside eval(quote ...),
|
||||
# and constructing the module AST by hand is fragile.
|
||||
# Instead, we generate the full module source as a string,
|
||||
# parse it, and eval the resulting expression.
|
||||
# Each tool file declares its own dependencies via `using` statements
|
||||
# at the top of the file — the registry only injects `using ..type`
|
||||
# to make core types (agentTool, textContent, etc.) available.
|
||||
file_content = read(filepath, String)
|
||||
module_code = """
|
||||
module $(mod_name)
|
||||
using ..type
|
||||
$(file_content)
|
||||
end
|
||||
"""
|
||||
mod = eval(Meta.parse(module_code))
|
||||
|
||||
# Call getTool() via Core.eval in the submodule's scope.
|
||||
# This evaluates getTool() entirely within the new module's world,
|
||||
# completely avoiding world-age issues — no invokelatest needed.
|
||||
# Note: all uses of `tool` must be inside the `try` block because
|
||||
# Julia 1.12's SSA form doesn't track `tool` as definitely assigned
|
||||
# after a `try-catch` where it's only assigned inside `try`.
|
||||
try
|
||||
tool = Core.eval(mod, :(getTool()))
|
||||
if !(tool isa agentTool)
|
||||
throw(ArgumentError(
|
||||
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
|
||||
))
|
||||
end
|
||||
store.tools[tool.name] = tool
|
||||
println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))")
|
||||
catch e
|
||||
if e isa UndefVarError || occursin("getTool", sprint(showerror, e))
|
||||
throw(ArgumentError(
|
||||
"Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " *
|
||||
"Each tool file must define: function getTool()::agentTool ... end"
|
||||
))
|
||||
end
|
||||
rethrow(e)
|
||||
end
|
||||
end
|
||||
|
||||
return store.tools
|
||||
end
|
||||
|
||||
"""
|
||||
Register a single agentTool into a specific ToolStore.
|
||||
|
||||
# Arguments
|
||||
- `store::ToolStore`: The tool store to register into
|
||||
- `tool::agentTool`: The tool to register
|
||||
|
||||
# Returns
|
||||
- `OrderedDict{String, agentTool}`: Updated tool dict for this store
|
||||
|
||||
# Examples
|
||||
```julia
|
||||
julia> store = ToolStore(name="agent1")
|
||||
julia> registerTool(store, my_tool)
|
||||
[toolRegistry:agent1] Registered tool: my_tool
|
||||
```
|
||||
"""
|
||||
function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool}
|
||||
function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool}
|
||||
store.tools[tool.name] = tool
|
||||
println("[$(store.name)] Registered tool: $(tool.name)")
|
||||
return store.tools
|
||||
end
|
||||
|
||||
"""
|
||||
Get the registered tools from a specific ToolStore.
|
||||
Return the tools registered in `store`.
|
||||
|
||||
Returns the internal `OrderedDict` directly — O(1) lookup by name,
|
||||
ordered iteration preserving registration order.
|
||||
The returned dict is the **same object** stored inside `store` — mutations
|
||||
to it (e.g. via `registerTool`) are visible through subsequent calls.
|
||||
|
||||
# Arguments
|
||||
- `store::ToolStore`: The tool store to query
|
||||
- `store`: Tool store to query
|
||||
|
||||
# Returns
|
||||
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
|
||||
|
||||
# Examples
|
||||
# Example
|
||||
```julia
|
||||
julia> getTools(store)
|
||||
OrderedDict{String, agentTool} with 3 entries:
|
||||
"listTools" => agentTool(...)
|
||||
julia> tools = getTools(store)
|
||||
OrderedDict{String, agentTool} with 2 entries:
|
||||
"getWeather" => agentTool(...)
|
||||
"getTime" => agentTool(...)
|
||||
```
|
||||
"""
|
||||
function getTools(store::ToolStore)::OrderedDict{String, agentTool}
|
||||
function getTools(store::toolStore)::OrderedDict{String, agentTool}
|
||||
return store.tools
|
||||
end
|
||||
|
||||
"""
|
||||
Clear all registered tools from a specific ToolStore.
|
||||
Remove all tools from `store`.
|
||||
|
||||
# Arguments
|
||||
- `store::ToolStore`: The tool store to clear
|
||||
- `store`: Tool store to clear
|
||||
|
||||
# Returns
|
||||
- `nothing`
|
||||
|
||||
# Examples
|
||||
# Example
|
||||
```julia
|
||||
julia> clearTools(store)
|
||||
[toolRegistry:agent1] Registry cleared
|
||||
nothing
|
||||
|
||||
julia> getTools(store)
|
||||
OrderedDict{String, agentTool} with 0 entries
|
||||
```
|
||||
"""
|
||||
function clearTools(store::ToolStore)::Nothing
|
||||
function clearTools(store::toolStore)::Nothing
|
||||
empty!(store.tools)
|
||||
println("[$(store.name)] Registry cleared")
|
||||
return nothing
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using .type
|
||||
using Dates
|
||||
|
||||
"""
|
||||
@@ -15,7 +16,7 @@ Demonstrates custom validation beyond simple required-field checking:
|
||||
- `nothing` if validation passes
|
||||
- `String` error message if validation fails
|
||||
"""
|
||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
tz = get(args, "timezone", nothing)
|
||||
city = get(args, "city", "")
|
||||
|
||||
@@ -43,7 +44,8 @@ Execute the getTime tool.
|
||||
|
||||
Returns mock time data for the given timezone or city.
|
||||
"""
|
||||
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
|
||||
function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
|
||||
onPartialResult)
|
||||
tz = get(args, "timezone", nothing)
|
||||
city = get(args, "city", "")
|
||||
if tz !== nothing
|
||||
@@ -60,7 +62,7 @@ end
|
||||
"""
|
||||
Define and return the getTime agentTool.
|
||||
"""
|
||||
function getTool()::agentTool
|
||||
function getTimeTool()::agentTool
|
||||
return agentTool(
|
||||
name = "getTime",
|
||||
label = "Time Lookup",
|
||||
@@ -73,9 +75,9 @@ function getTool()::agentTool
|
||||
),
|
||||
"required" => []
|
||||
),
|
||||
execute = executeTool,
|
||||
execute = getTimeExecute,
|
||||
prepareArguments = nothing,
|
||||
validateRequiredArgs = validateRequiredArgs,
|
||||
validateRequiredArgs = getTimeValidateRequiredArgs,
|
||||
parallelToolExecute = false
|
||||
)
|
||||
end
|
||||
|
||||
+21
-11
@@ -1,23 +1,33 @@
|
||||
using msghandler
|
||||
using .type
|
||||
|
||||
"""
|
||||
Execute the getWeather tool.
|
||||
|
||||
Returns mock weather data for the given city and temperature units.
|
||||
"""
|
||||
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
|
||||
city = get(args, "city", "")
|
||||
units = get(args, "units", "celsius")
|
||||
temp = units == "fahrenheit" ? "72" : "22"
|
||||
unit_symbol = units == "celsius" ? "°C" : "°F"
|
||||
return agentToolResult(
|
||||
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
|
||||
Dict{Any,Any}(), nothing, false
|
||||
)
|
||||
function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
|
||||
agentEventSink)
|
||||
|
||||
agentEventSink("Getting weather...")
|
||||
|
||||
city = get(args, "city", "")
|
||||
units = get(args, "units", "celsius")
|
||||
temp = units == "fahrenheit" ? "72" : "22"
|
||||
unit_symbol = units == "celsius" ? "°C" : "°F"
|
||||
|
||||
return agentToolResult(
|
||||
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
|
||||
Dict{Any,Any}(),
|
||||
nothing,
|
||||
false
|
||||
)
|
||||
end
|
||||
|
||||
"""
|
||||
Define and return the getWeather agentTool.
|
||||
"""
|
||||
function getTool()::agentTool
|
||||
function getWeatherTool()::agentTool
|
||||
return agentTool(
|
||||
name = "getWeather",
|
||||
label = "Weather Lookup",
|
||||
@@ -30,7 +40,7 @@ function getTool()::agentTool
|
||||
),
|
||||
"required" => ["city"]
|
||||
),
|
||||
execute = executeTool,
|
||||
execute = getWeatherExecute,
|
||||
prepareArguments = nothing,
|
||||
validateRequiredArgs = nothing,
|
||||
parallelToolExecute = false
|
||||
|
||||
+12
-9
@@ -1,3 +1,4 @@
|
||||
using .type
|
||||
using JSON
|
||||
|
||||
"""
|
||||
@@ -7,15 +8,17 @@ The agent can use this tool when it encounters a task that no existing tool
|
||||
can handle. Provide the tool's name, label, description, inputSchema, and
|
||||
execute logic as Julia code. The tool is written to `src/tools/<name>.jl`.
|
||||
|
||||
After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks
|
||||
up the new file. The new tool is immediately available.
|
||||
After calling this tool, add the new file to `YiemAgent.jl` with an `include()`
|
||||
statement (after `include("toolRegistry.jl")`), then restart the agent.
|
||||
The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`.
|
||||
|
||||
# Example
|
||||
|
||||
1. Agent calls writeTool with a spec for a "searchWine" tool
|
||||
2. writeTool generates src/tools/searchWine.jl
|
||||
3. Restart agent — loadTools() picks up the new file
|
||||
4. Agent calls searchWine with args
|
||||
3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
|
||||
4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools()
|
||||
5. Restart agent — new tool is available
|
||||
|
||||
# How It Works
|
||||
|
||||
@@ -24,7 +27,7 @@ tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate:
|
||||
- Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string
|
||||
- Indents `executeCode` with 4 spaces
|
||||
- Wraps it inside `function executeTool(...)::agentToolResult ... end`
|
||||
- Appends `getTool()` returning an `agentTool` struct
|
||||
- Appends `writeToolTool()` returning an `agentTool` struct
|
||||
- Writes the combined string to `src/tools/<name>.jl`
|
||||
|
||||
# Important Notes
|
||||
@@ -105,7 +108,7 @@ end
|
||||
"""
|
||||
Define and return the writeTool agentTool.
|
||||
"""
|
||||
function getTool()::agentTool
|
||||
function writeToolTool()::agentTool
|
||||
return agentTool(
|
||||
name = "writeTool",
|
||||
label = "Create Tool",
|
||||
@@ -127,7 +130,7 @@ function getTool()::agentTool
|
||||
),
|
||||
"required" => ["name", "label", "description", "inputSchema", "executeCode"]
|
||||
),
|
||||
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin
|
||||
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin
|
||||
tool_name = get(args, "name", "")::String
|
||||
tool_label = get(args, "label", tool_name)::String
|
||||
tool_description = get(args, "description", "")::String
|
||||
@@ -250,13 +253,13 @@ function getTool()::agentTool
|
||||
|
||||
tool_code = join(parts)
|
||||
|
||||
# Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools")
|
||||
# Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools()
|
||||
write(filepath, tool_code)
|
||||
|
||||
onPartialResult(Dict("status" => "Done"))
|
||||
|
||||
return agentToolResult(
|
||||
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")],
|
||||
[textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")],
|
||||
Dict{Any,Any}(
|
||||
"file" => filepath,
|
||||
"name" => tool_name,
|
||||
|
||||
+96
-182
@@ -4,17 +4,17 @@
|
||||
messageContent, agentMessage, agent,
|
||||
# Model types
|
||||
modelCost, llmModel, llmUsage,
|
||||
# Message content types
|
||||
textContent, imageContent,
|
||||
# Message types
|
||||
userMessage, assistantMessage, toolResultMessage,
|
||||
# Message content types
|
||||
textContent, imageContent, reasoningContent,
|
||||
# Message types
|
||||
userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage,
|
||||
# Tool types
|
||||
agentTool, validateRequiredArgs,
|
||||
# Context types
|
||||
agentContext, agentState, agentToolCall, prepareNextTurnContext,
|
||||
# Loop & execution types
|
||||
agentLoopConfig, abortSignal, agentToolResult,
|
||||
assistantMsgCtx, afterCtx,
|
||||
agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext,
|
||||
beforeToolCallResult, afterToolCallContext,
|
||||
# Event types
|
||||
toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent,
|
||||
# Agent
|
||||
@@ -23,7 +23,7 @@
|
||||
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
|
||||
agentToolCallBatch,
|
||||
# Functions (defined elsewhere)
|
||||
run_agent, take_response, follow_up, stop_agent
|
||||
runAgent, takeResponse, followUp, stopAgent
|
||||
|
||||
|
||||
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
|
||||
@@ -31,6 +31,13 @@ using GeneralUtils
|
||||
|
||||
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 #
|
||||
# ------------------------------------------------------------------------------------------------ #
|
||||
@@ -75,6 +82,10 @@ struct imageContent <: messageContent # Image message content
|
||||
mimeType::String # MIME type (e.g., "image/png")
|
||||
end
|
||||
|
||||
struct reasoningContent <: messageContent # LLM reasoning/thinking content
|
||||
text::String # The reasoning text
|
||||
end
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ #
|
||||
# Message types #
|
||||
@@ -108,6 +119,52 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp=
|
||||
return userMessage(role, content, timestamp)
|
||||
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
|
||||
role::String # Always "assistant"
|
||||
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}(),
|
||||
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())
|
||||
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
|
||||
|
||||
struct toolResultMessage <: agentMessage # Result returned from a tool execution
|
||||
@@ -263,7 +321,7 @@ struct agentTool # A tool available to the agent
|
||||
label::String # Human-readable tool name
|
||||
description::String # What the tool does
|
||||
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
|
||||
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
|
||||
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
|
||||
@@ -273,7 +331,7 @@ end
|
||||
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
|
||||
"""
|
||||
function agentTool(; name::String, label::String, description::String, inputSchema::Any,
|
||||
execute::Function, prepareArguments::Union{Function, Nothing}=nothing,
|
||||
execute, prepareArguments::Union{Function, Nothing}=nothing,
|
||||
validateRequiredArgs::Union{Function, Nothing}=nothing,
|
||||
parallelToolExecute::Bool=false)
|
||||
return agentTool(name, label, description, inputSchema, execute,
|
||||
@@ -291,7 +349,7 @@ Snapshot of the agent's conversation context.
|
||||
# Arguments
|
||||
- `systemPrompt::String`: System prompt for the agent
|
||||
- `messages::Vector{agentMessage}`: Conversation messages
|
||||
- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
|
||||
- `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
|
||||
|
||||
# Returns
|
||||
- A new `agentContext` instance
|
||||
@@ -299,7 +357,7 @@ Snapshot of the agent's conversation context.
|
||||
struct agentContext # Snapshot of the agent's conversation context
|
||||
systemPrompt::String # System prompt for the agent
|
||||
messages::Vector{agentMessage} # Conversation messages
|
||||
tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name
|
||||
tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
|
||||
end
|
||||
|
||||
|
||||
@@ -309,14 +367,13 @@ end
|
||||
|
||||
mutable struct agentState # Mutable runtime state of an agent
|
||||
systemPrompt::String # System prompt for the agent
|
||||
model::llmModel # LLM model to use
|
||||
model::Union{llmModel, Nothing} # LLM model to use
|
||||
tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered
|
||||
|
||||
# messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
|
||||
messages::Vector{agentMessage}
|
||||
|
||||
pendingToolCalls::Vector{String} # Tool call IDs waiting for results
|
||||
activeRun::Bool # is agent processing user message?
|
||||
errorMessage::Union{String, Nothing} # Last error message
|
||||
end
|
||||
|
||||
@@ -341,29 +398,20 @@ julia> state = agentState(systemPrompt="You are a helpful assistant")
|
||||
agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing)
|
||||
"""
|
||||
function agentState(
|
||||
systemPrompt::String="",
|
||||
model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[],
|
||||
modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
|
||||
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
|
||||
messages::Vector{agentMessage}=agentMessage[],
|
||||
systemPrompt::String="",
|
||||
model=llmModel("model_1", "unknown", "unknown", "", false, String[],
|
||||
modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
|
||||
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
|
||||
messages::Vector{agentMessage}=agentMessage[],
|
||||
)
|
||||
agentState(
|
||||
systemPrompt,
|
||||
model,
|
||||
deepcopy(tools),
|
||||
deepcopy(messages),
|
||||
Vector{String}(),
|
||||
false,
|
||||
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
|
||||
agentState(
|
||||
systemPrompt,
|
||||
model,
|
||||
deepcopy(tools),
|
||||
deepcopy(messages),
|
||||
Vector{String}(),
|
||||
nothing,
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -395,13 +443,11 @@ end
|
||||
Configuration for the agent tool execution loop.
|
||||
|
||||
# Arguments
|
||||
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
|
||||
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
||||
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
||||
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
||||
"""
|
||||
struct agentLoopConfig
|
||||
tools::OrderedDict{String, agentTool}
|
||||
beforeToolCall::Union{Function, Nothing}
|
||||
afterToolCall::Union{Function, Nothing}
|
||||
toolExecution::String
|
||||
@@ -437,31 +483,36 @@ end
|
||||
Context passed to the `beforeToolCall` hook.
|
||||
|
||||
# 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
|
||||
- `args::Dict{String,Any}`: Validated tool arguments
|
||||
- `context::agentContext`: Current conversation context
|
||||
"""
|
||||
struct assistantMsgCtx
|
||||
message::assistantMessage
|
||||
struct beforeToolCallContext
|
||||
message::assistantMessageToolCall
|
||||
toolCall::agentToolCall
|
||||
args::Dict{String,Any}
|
||||
context::agentContext
|
||||
end
|
||||
|
||||
struct beforeToolCallResult
|
||||
block::Bool
|
||||
reason::String
|
||||
end
|
||||
|
||||
"""
|
||||
Context passed to the `afterToolCall` hook.
|
||||
|
||||
# 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
|
||||
- `args::Dict{String,Any}`: Tool arguments
|
||||
- `result::agentToolResult`: The raw tool result
|
||||
- `isError::Bool`: Whether execution resulted in an error
|
||||
- `context::agentContext`: Current conversation context
|
||||
"""
|
||||
struct afterCtx
|
||||
message::assistantMessage
|
||||
struct afterToolCallContext
|
||||
message::assistantMessageToolCall
|
||||
toolCall::agentToolCall
|
||||
args::Dict{String,Any}
|
||||
result::agentToolResult
|
||||
@@ -521,143 +572,6 @@ 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
|
||||
_tool_store::Any # Reference to the ToolStore for runtime registration
|
||||
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::OrderedDict{String, agentTool}`: Available tools keyed by name (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
|
||||
- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`)
|
||||
|
||||
# Returns
|
||||
- A new `yiemAgent` instance with an active background task
|
||||
|
||||
# Examples
|
||||
```julia
|
||||
julia> store = ToolStore(name="agent1")
|
||||
julia> tools = loadTools(store, "src/tools")
|
||||
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store)
|
||||
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store)
|
||||
"""
|
||||
function yiemAgent(
|
||||
; systemPrompt::String="You are helpful assistant.",
|
||||
model=nothing,
|
||||
tools::OrderedDict{String, agentTool}=OrderedDict{String, 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,
|
||||
tool_store::Union{Any, Nothing}=nothing,
|
||||
)
|
||||
# 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,
|
||||
tool_store,
|
||||
)
|
||||
|
||||
# Spawn the background loop and attach it
|
||||
agent._agent_loop = @spawn _agent_loop(agent)
|
||||
|
||||
return agent
|
||||
end
|
||||
|
||||
|
||||
|
||||
"""
|
||||
preparedToolCall(tool, toolCall, args)
|
||||
|
||||
|
||||
+193
-14
@@ -1,9 +1,11 @@
|
||||
module utils
|
||||
|
||||
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI,
|
||||
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks
|
||||
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
|
||||
validateToolArguments, _userMessageToOpenAI,
|
||||
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
|
||||
beforeToolCall, afterToolCall, agentEventSink
|
||||
|
||||
using UUIDs, Dates, DataStructures, HTTP, JSON
|
||||
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
|
||||
using GeneralUtils
|
||||
using ..type
|
||||
|
||||
@@ -73,7 +75,6 @@ function availableWineToText(vecd::Vector)::String
|
||||
end
|
||||
|
||||
|
||||
|
||||
"""
|
||||
prepareContext(state::agentState) -> agentContext
|
||||
|
||||
@@ -108,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages)
|
||||
# end
|
||||
```
|
||||
"""
|
||||
function prepareContext(state::agentState)::agentContext
|
||||
function prepareContext(state::agentState, agentEventSink)::agentContext
|
||||
|
||||
#TODO filter tools from state.tools based on user intend in user message and tool description
|
||||
filteredTools = state.tools
|
||||
@@ -155,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
|
||||
])
|
||||
```
|
||||
"""
|
||||
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
|
||||
|
||||
""" openai message format example
|
||||
msg = Dict(
|
||||
@@ -183,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
Dict("type" => "text", "text" => "let me check."),
|
||||
]
|
||||
),
|
||||
],
|
||||
"tools"=> [
|
||||
Dict(
|
||||
"role" => "toolResult",
|
||||
"content" => [
|
||||
Dict("type" => "text", "text" => "name: Chateau Montelena ..."),
|
||||
]
|
||||
),
|
||||
"type" => "function",
|
||||
"function" => Dict(
|
||||
"name" => "getWeather",
|
||||
"description" => "Get current weather",
|
||||
"parameters" => Dict(
|
||||
"type" => "object",
|
||||
"properties" => Dict(
|
||||
"city" => Dict("type" => "string")
|
||||
),
|
||||
"required" => ["city"]
|
||||
)
|
||||
)
|
||||
)
|
||||
],
|
||||
"temperature" => 0.7
|
||||
)
|
||||
"""
|
||||
|
||||
openaiReadyMsg = Dict{String, Any}()
|
||||
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
|
||||
messages = Vector{Dict{String, Any}}()
|
||||
|
||||
agentEventSink("formatMsgForLLM 1")
|
||||
# System prompt as system message
|
||||
if !isempty(ctx.systemPrompt)
|
||||
push!(messages, Dict(
|
||||
@@ -203,19 +216,113 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
|
||||
))
|
||||
end
|
||||
|
||||
agentEventSink("formatMsgForLLM 2")
|
||||
# Conversation messages
|
||||
for msg in ctx.messages
|
||||
if msg isa userMessage
|
||||
push!(messages, _userMessageToOpenAI(msg))
|
||||
elseif msg isa assistantMessageToolCall
|
||||
push!(messages, _assistantMessageToolCallToOpenAI(msg))
|
||||
elseif msg isa assistantMessage
|
||||
push!(messages, _assistantMessageToOpenAI(msg))
|
||||
elseif msg isa toolResultMessage
|
||||
push!(messages, _toolResultMessageToOpenAI(msg))
|
||||
end
|
||||
end
|
||||
agentEventSink("formatMsgForLLM 3")
|
||||
# Convert ctx.tools into OpenAI tools format
|
||||
tools_array = _toolsToOpenAI(ctx.tools, agentEventSink)
|
||||
agentEventSink("formatMsgForLLM 4")
|
||||
openaiReadyMsg["messages"] = messages
|
||||
openaiReadyMsg["temperature"] = 0.7
|
||||
|
||||
return Dict("messages" => messages)
|
||||
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 agentEventSink(x)
|
||||
|
||||
end
|
||||
|
||||
|
||||
@@ -231,6 +338,44 @@ 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.
|
||||
"""
|
||||
function _assistantMessageToOpenAI(msg::assistantMessage)::Dict{String, Any}
|
||||
@@ -279,6 +424,40 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
_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}, agentEventSink)::Vector{Dict{String, Any}}
|
||||
tools_array = Vector{Dict{String, Any}}()
|
||||
agentEventSink("_toolsToOpenAI 1")
|
||||
agentEventSink(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
|
||||
agentEventSink("_toolsToOpenAI 2")
|
||||
return tools_array
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
|
||||
|
||||
|
||||
@@ -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
@@ -1,401 +1,5 @@
|
||||
using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64,
|
||||
NATS, Base.Threads
|
||||
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")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
using Test
|
||||
using YiemAgent
|
||||
|
||||
include("toolTest.jl")
|
||||
include("_extractToolCalls.jl")
|
||||
|
||||
+232
-38
@@ -1,52 +1,36 @@
|
||||
using Test
|
||||
using Dates
|
||||
using YiemAgent
|
||||
using YiemAgent.toolRegistry
|
||||
using YiemAgent.type
|
||||
using YiemAgent.agentCore
|
||||
|
||||
# Path to the real tools directory
|
||||
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
|
||||
@testset "loadTools with ToolStore" begin
|
||||
@testset "register_all_tools with toolStore" begin
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. loadTools throws on non-existent directory #
|
||||
# 1. register_all_tools registers all static tools + listTools #
|
||||
# ------------------------------------------------------------------ #
|
||||
store = ToolStore(name="test1")
|
||||
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. loadTools throws if a .jl file does not define getTool() #
|
||||
# Must run BEFORE any other loadTools call (getTool binding #
|
||||
# persists in module scope after include()). #
|
||||
# ------------------------------------------------------------------ #
|
||||
bad_dir = mktempdir()
|
||||
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
|
||||
@test_throws ArgumentError loadTools(store, bad_dir)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. loadTools loads actual tool files from src/tools/ #
|
||||
# ------------------------------------------------------------------ #
|
||||
store2 = ToolStore(name="test2")
|
||||
loaded = loadTools(store2, TOOLS_DIR)
|
||||
store = toolStore(name="test1")
|
||||
loaded = register_all_tools(store)
|
||||
@test !isempty(loaded)
|
||||
@test length(loaded) == 3
|
||||
@test length(loaded) == 4 # getWeather + getTime + writeTool + listTools
|
||||
|
||||
names = [k for k in keys(loaded)]
|
||||
@test "getTime" in names
|
||||
@test "getWeather" in names
|
||||
@test "writeTool" in names
|
||||
@test "listTools" in names
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 4. loadTools returns tools sorted alphabetically by filename #
|
||||
# (getTime.jl < getWeather.jl < writeTool.jl) #
|
||||
# because 'T' < 'W' in ASCII #
|
||||
# 2. register_all_tools returns tools in registration order #
|
||||
# ------------------------------------------------------------------ #
|
||||
@test collect(keys(loaded))[1] == "getTime"
|
||||
@test collect(keys(loaded))[2] == "getWeather"
|
||||
@test collect(keys(loaded))[1] == "getWeather"
|
||||
@test collect(keys(loaded))[2] == "getTime"
|
||||
@test collect(keys(loaded))[3] == "writeTool"
|
||||
@test collect(keys(loaded))[4] == "listTools"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 5. Verify loaded tool fields are correct #
|
||||
# 3. Verify loaded tool fields are correct #
|
||||
# ------------------------------------------------------------------ #
|
||||
# getTime
|
||||
time_tool = loaded["getTime"]
|
||||
@@ -73,7 +57,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test "executeCode" in wt.inputSchema["required"]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 6. Tool execution returns valid results #
|
||||
# 4. Tool execution returns valid results #
|
||||
# ------------------------------------------------------------------ #
|
||||
sig = nothing
|
||||
op = x -> x # no-op partial result callback
|
||||
@@ -97,16 +81,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
|
||||
# execute getWeather with units
|
||||
result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op)
|
||||
@test occursin("72°F", result_w2.content[1].text)
|
||||
@test occursin("72\u00b0F", result_w2.content[1].text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 7. getTools / registerTool / clearTools (per-store isolation) #
|
||||
# 5. getTools / registerTool / clearTools (per-store isolation) #
|
||||
# ------------------------------------------------------------------ #
|
||||
store3 = ToolStore(name="test3")
|
||||
store3 = toolStore(name="test3")
|
||||
registry_tools = getTools(store3)
|
||||
@test isempty(registry_tools)
|
||||
|
||||
# listTool is not auto-registered anymore — each store starts empty
|
||||
# Register tools manually
|
||||
registerTool(store3, loaded["getTime"])
|
||||
registerTool(store3, loaded["getWeather"])
|
||||
@@ -142,7 +125,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test reg["manualTool"].parallelToolExecute == true
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 8. getTools returns direct reference (mutations affect registry) #
|
||||
# 6. getTools returns direct reference (mutations affect registry) #
|
||||
# ------------------------------------------------------------------ #
|
||||
copy1 = getTools(store3)
|
||||
copy2 = getTools(store3)
|
||||
@@ -151,10 +134,10 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test isempty(getTools(store3)) # mutation propagates
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 9. Per-store isolation — two stores don't share tools #
|
||||
# 7. Per-store isolation — two stores don't share tools #
|
||||
# ------------------------------------------------------------------ #
|
||||
storeA = ToolStore(name="isolationA")
|
||||
storeB = ToolStore(name="isolationB")
|
||||
storeA = toolStore(name="isolationA")
|
||||
storeB = toolStore(name="isolationB")
|
||||
|
||||
registerTool(storeA, loaded["getTime"])
|
||||
registerTool(storeB, loaded["getWeather"])
|
||||
@@ -171,3 +154,214 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test isempty(getTools(storeA))
|
||||
@test !isempty(getTools(storeB)) # storeB unaffected
|
||||
end
|
||||
|
||||
@testset "listTool" begin
|
||||
store = toolStore(name="test_list")
|
||||
register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools
|
||||
|
||||
# register_all_tools auto-registers listTool
|
||||
@test "listTools" in keys(store.tools)
|
||||
|
||||
# listTool returns an agentTool, not a string or array
|
||||
list_t = listTool(store)
|
||||
@test list_t isa agentTool
|
||||
@test list_t.name == "listTools"
|
||||
@test list_t.label == "List Tools"
|
||||
@test isempty(list_t.inputSchema["required"])
|
||||
|
||||
# Verify all tools appear (3 loaded + listTools = 4)
|
||||
result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x)
|
||||
@test result isa agentToolResult
|
||||
@test result.content[1] isa textContent
|
||||
@test occursin("listTools", result.content[1].text)
|
||||
@test occursin("getWeather", result.content[1].text)
|
||||
@test occursin("getTime", result.content[1].text)
|
||||
@test occursin("writeTool", result.content[1].text)
|
||||
@test result.details["count"] == 4
|
||||
|
||||
# Each listTool call creates an independent closure
|
||||
storeB = toolStore(name="test_listB")
|
||||
registerTool(storeB, store.tools["getWeather"])
|
||||
list_tB = listTool(storeB)
|
||||
|
||||
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x)
|
||||
resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x)
|
||||
|
||||
@test occursin("getWeather", resultA.content[1].text)
|
||||
@test occursin("getWeather", resultB.content[1].text)
|
||||
@test occursin("getTime", resultA.content[1].text)
|
||||
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
|
||||
end
|
||||
|
||||
@testset "executePreparedToolCall with static tools" begin
|
||||
# Tests executePreparedToolCall with statically loaded tools.
|
||||
# The world-age issue is resolved because tool.execute comes from
|
||||
# a statically included module, not a dynamically created one.
|
||||
|
||||
store = toolStore(name="test_static")
|
||||
register_all_tools(store)
|
||||
|
||||
weather_tool = store.tools["getWeather"]
|
||||
|
||||
# Create a preparedToolCall that mimics what prepareToolCall() returns
|
||||
tool_call = agentToolCall(
|
||||
"function", "call-static-1", "getWeather",
|
||||
Dict{String,Any}("city" => "San Francisco")
|
||||
)
|
||||
prep = preparedToolCall(
|
||||
weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco")
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
# This call goes through: executePreparedToolCall -> prep.tool.execute(...)
|
||||
result = executePreparedToolCall(
|
||||
prep, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test result isa executedOutcome
|
||||
@test result.isError == false
|
||||
@test result.result.content[1] isa textContent
|
||||
@test occursin("San Francisco", result.result.content[1].text)
|
||||
end
|
||||
|
||||
@testset "executePreparedToolCall with validation (static tools)" begin
|
||||
# Tests executePreparedToolCall with a tool that has custom validation hooks.
|
||||
# This exercises the full tool execution path including validation.
|
||||
|
||||
store = toolStore(name="test_static_validate")
|
||||
register_all_tools(store)
|
||||
|
||||
time_tool = store.tools["getTime"]
|
||||
|
||||
tool_call = agentToolCall(
|
||||
"function", "call-static-2", "getTime",
|
||||
Dict{String,Any}("timezone" => "America/New_York")
|
||||
)
|
||||
prep = preparedToolCall(
|
||||
time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York")
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
result = executePreparedToolCall(
|
||||
prep, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test result isa executedOutcome
|
||||
@test result.isError == false
|
||||
@test result.result.content[1] isa textContent
|
||||
@test occursin("America/New_York", result.result.content[1].text)
|
||||
end
|
||||
|
||||
@testset "executeToolCallsSequential with static tools (full pipeline)" begin
|
||||
# Tests the full tool execution pipeline: executeToolCallsSequential
|
||||
# which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall
|
||||
# with statically loaded tools.
|
||||
|
||||
store = toolStore(name="test_full_pipeline")
|
||||
register_all_tools(store)
|
||||
|
||||
# Build agentContext from the store's tools
|
||||
tools = getTools(store)
|
||||
ctx = agentContext(
|
||||
"test system prompt",
|
||||
agentMessage[],
|
||||
tools
|
||||
)
|
||||
|
||||
# Create an assistant message containing tool calls
|
||||
assistant_msg = assistantMessage(
|
||||
role="assistant",
|
||||
content=Vector{messageContent}(),
|
||||
api="openai",
|
||||
provider="test",
|
||||
model="test-model",
|
||||
usage=llmUsage(0, 0),
|
||||
stopReason="tool_calls",
|
||||
errorMessage=nothing,
|
||||
timestamp=now()
|
||||
)
|
||||
|
||||
# Create tool calls for multiple statically loaded tools
|
||||
tool_calls = [
|
||||
agentToolCall(
|
||||
"function", "call-seq-1", "getWeather",
|
||||
Dict{String,Any}("city" => "Tokyo")
|
||||
),
|
||||
agentToolCall(
|
||||
"function", "call-seq-2", "getTime",
|
||||
Dict{String,Any}("timezone" => "Europe/London")
|
||||
),
|
||||
]
|
||||
|
||||
config = agentLoopConfig(
|
||||
nothing, nothing, "sequential"
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
# Execute the full pipeline
|
||||
batch = executeToolCallsSequential(
|
||||
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test batch.messages isa Vector{toolResultMessage}
|
||||
@test length(batch.messages) == 2
|
||||
@test batch.messages[1].toolName == "getWeather"
|
||||
@test batch.messages[1].isError == false
|
||||
@test occursin("Tokyo", batch.messages[1].content[1].text)
|
||||
@test batch.messages[2].toolName == "getTime"
|
||||
@test batch.messages[2].isError == false
|
||||
@test occursin("Europe/London", batch.messages[2].content[1].text)
|
||||
end
|
||||
|
||||
@testset "executeToolCallsParallel with static tools (full pipeline)" begin
|
||||
# Same as above but tests parallel execution path.
|
||||
|
||||
store = toolStore(name="test_parallel")
|
||||
register_all_tools(store)
|
||||
|
||||
tools = getTools(store)
|
||||
ctx = agentContext(
|
||||
"test system prompt",
|
||||
agentMessage[],
|
||||
tools
|
||||
)
|
||||
|
||||
assistant_msg = assistantMessage(
|
||||
role="assistant",
|
||||
content=Vector{messageContent}(),
|
||||
api="openai",
|
||||
provider="test",
|
||||
model="test-model",
|
||||
usage=llmUsage(0, 0),
|
||||
stopReason="tool_calls",
|
||||
errorMessage=nothing,
|
||||
timestamp=now()
|
||||
)
|
||||
|
||||
tool_calls = [
|
||||
agentToolCall(
|
||||
"function", "call-par-1", "getWeather",
|
||||
Dict{String,Any}("city" => "Paris")
|
||||
),
|
||||
agentToolCall(
|
||||
"function", "call-par-2", "getTime",
|
||||
Dict{String,Any}("city" => "Sydney")
|
||||
),
|
||||
]
|
||||
|
||||
config = agentLoopConfig(
|
||||
nothing, nothing, "parallel"
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
batch = executeToolCallsParallel(
|
||||
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test batch.messages isa Vector{toolResultMessage}
|
||||
@test length(batch.messages) == 2
|
||||
@test batch.messages[1].toolName == "getWeather"
|
||||
@test batch.messages[1].isError == false
|
||||
@test batch.messages[2].toolName == "getTime"
|
||||
@test batch.messages[2].isError == false
|
||||
end
|
||||
|
||||
+113
@@ -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
|
||||
|
||||
agentEventSink(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 agentEventSink
|
||||
natsConn::NATS.Connection
|
||||
topic::String
|
||||
senderID::String
|
||||
end
|
||||
|
||||
function (aes::agentEventSink)(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 = agentEventSink(agent_conn, "sommanion.debug", "sender")
|
||||
|
||||
agent = YiemAgent.yiemAgent(
|
||||
text2text_llm;
|
||||
agentEventSink=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)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user