This commit is contained in:
2026-08-09 11:53:13 +07:00
parent ed5415d92a
commit 8cde269c49
7 changed files with 291 additions and 275 deletions
+1 -2
View File
@@ -1,2 +1 @@
# ── executeToolCalls() Julia pseudo code ────────────────────────── run test again. loadToolTest.jl should be able to load all tools in ./src/tools and test them.
# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit
+30 -13
View File
@@ -36,6 +36,35 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
return nothing return nothing
end end
"""
Execute the getTime tool.
# Arguments
- `toolCallId::String`: Unique identifier for this tool call
- `args::Dict{String,Any}`: Parsed arguments from the LLM
- `signal::Union{Nothing,abortSignal}`: Optional abort signal
- `onPartialResult::Function`: Callback for streaming partial results
# Returns
- `agentToolResult`: Result content with current time data
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
# Simulate time lookup — replace with actual timezone API call
if tz !== nothing
result = "Current time in $(tz): $(now())"
else
result = "Current time in $(city): $(now())"
end
return agentToolResult(
[textContent(result)],
Dict{Any,Any}(), nothing, false
)
end
""" """
Define and return the getTime agentTool. Define and return the getTime agentTool.
""" """
@@ -52,19 +81,7 @@ function getTool()::agentTool
), ),
"required" => [] "required" => []
), ),
execute = (toolCallId, args, signal, onPartialResult) -> begin execute = executeTool,
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
if tz !== nothing
result = "Current time in $(tz): $(now())"
else
result = "Current time in $(city): $(now())"
end
return agentToolResult(
[textContent(result)],
Dict{Any,Any}(), nothing, false
)
end,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = validateRequiredArgs, validateRequiredArgs = validateRequiredArgs,
parallelToolExecute = false parallelToolExecute = false
+31 -10
View File
@@ -1,3 +1,33 @@
"""
Execute the getWeather tool.
# Arguments
- `toolCallId::String`: Unique identifier for this tool call
- `args::Dict{String,Any}`: Parsed arguments from the LLM
- `signal::Union{Nothing,abortSignal}`: Optional abort signal
- `onPartialResult::Function`: Callback for streaming partial results
# Returns
- `agentToolResult`: Result content with weather data
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
city = get(args, "city", "")
units = get(args, "units", "celsius")
# Simulate weather fetch — replace with actual API call
# You can call onPartialResult() here for streaming progress updates:
# onPartialResult(Dict("status" => "Fetching weather data..."))
# onPartialResult(Dict("status" => "Processing..."))
temp = units == "fahrenheit" ? "72" : "22"
unit_symbol = units == "celsius" ? "°C" : "°F"
return agentToolResult(
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
Dict{Any,Any}(), nothing, false
)
end
""" """
Define and return the getWeather agentTool. Define and return the getWeather agentTool.
""" """
@@ -14,16 +44,7 @@ function getTool()::agentTool
), ),
"required" => ["city"] "required" => ["city"]
), ),
execute = (toolCallId, args, signal, onPartialResult) -> begin execute = executeTool, # reference the function defined above
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,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = false parallelToolExecute = false
+32 -39
View File
@@ -44,7 +44,8 @@ function _listTool()::agentTool
end, end,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = false parallelToolExecute = false,
_tool_module = nothing
) )
end end
@@ -55,36 +56,9 @@ Scans `dir` for `.jl` files. Each file must define a function named
`getTool()::agentTool`. Files are sorted alphabetically so tool `getTool()::agentTool`. Files are sorted alphabetically so tool
registration order is deterministic. registration order is deterministic.
# Tool file format Each tool file is loaded into its own isolated module (a child of `toolRegistry`)
Each `.jl` file defines one function `getTool()` that returns an `agentTool`: so that functions like `executeTool` and `validateRequiredArgs` do not collide
across tool files. The module reference is stored in `agentTool._tool_module`.
```julia
# src/tools/getWeather.jl
function getTool()::agentTool
return agentTool(
name = "getWeather",
label = "Weather Lookup",
description = "Fetch current weather and forecast for a given city.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string", "description" => "City and country"),
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
),
"required" => ["city"]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
city = args["city"]
return agentToolResult(
[textContent("Sunny, 22C in Bangkok")],
Dict{Any,Any}(), nothing, false
)
end,
prepareArguments = nothing,
parallelToolExecute = false
)
end
```
# Arguments # Arguments
- `dir::String`: Directory path to scan for `.jl` tool files - `dir::String`: Directory path to scan for `.jl` tool files
@@ -108,27 +82,46 @@ function loadTools(dir::String)::Vector{agentTool}
filepath = joinpath(dir, filename) filepath = joinpath(dir, filename)
println("[toolRegistry] Loading tool from: $filepath") println("[toolRegistry] Loading tool from: $filepath")
# Include the file in the current module scope so all types resolve # Create a unique module for this tool (child of toolRegistry so ...type resolves)
# (agentTool, textContent, agentToolResult, etc. are all available) base_name = replace(filename, ".jl" => "")
include(filepath) mod_name = Symbol("Tool_", replace(base_name, r"[^a-zA-Z0-9_]" => "_"))
# Validate that getTool was defined (include() places it in current module scope) Core.eval(toolRegistry, :(module $mod_name
if !isdefined(@__MODULE__, :getTool) using ...type
using Dates
using JSON
end))
tool_mod = getfield(toolRegistry, mod_name)
# Include the tool file — defines getTool() and executeTool() in tool_mod
Base.include(tool_mod, filepath)
# Validate that getTool was defined
if !isdefined(tool_mod, :getTool)
throw(ArgumentError( throw(ArgumentError(
"Tool file $(filepath) does not define a `getTool()` function. " * "Tool file $(filepath) does not define a `getTool()` function. " *
"Each tool file must define: function getTool()::agentTool ... end" "Each tool file must define: function getTool()::agentTool ... end"
)) ))
end end
# Call getTool() — it runs in current scope where types are visible # Call getTool() in the tool module's scope
# Use invokelatest to handle world-age semantics after include() # Use invokelatest to handle world-age semantics after include()
tool = invokelatest(getTool) tool = invokelatest(getfield(tool_mod, :getTool))
if !(tool isa agentTool) if !(tool isa agentTool)
throw(ArgumentError( throw(ArgumentError(
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
)) ))
end end
# Reconstruct tool with _tool_module (agentTool is immutable)
tool = agentTool(; name=tool.name, label=tool.label, description=tool.description,
inputSchema=tool.inputSchema, execute=tool.execute,
prepareArguments=tool.prepareArguments,
validateRequiredArgs=tool.validateRequiredArgs,
parallelToolExecute=tool.parallelToolExecute,
_tool_module=tool_mod)
push!(_registry, tool) push!(_registry, tool)
push!(tools, tool) push!(tools, tool)
println("[toolRegistry] Loaded tool: $(tool.name)$(tool.label)") println("[toolRegistry] Loaded tool: $(tool.name)$(tool.label)")
@@ -159,7 +152,7 @@ Get all registered tools.
- `Vector{agentTool}`: Copy of the registry - `Vector{agentTool}`: Copy of the registry
""" """
function getTools()::Vector{agentTool} function getTools()::Vector{agentTool}
return deepcopy(_registry) return [t for t in _registry] # new vector with same references (agentTool is immutable, Modules can't be deepcopied)
end end
""" """
+33 -27
View File
@@ -101,31 +101,11 @@ function json_string(v)::String
end end
""" """
Define and return the writeTool agentTool. Execute the writeTool.
Generates a new .jl tool file and registers it with the tool registry.
""" """
function getTool()::agentTool function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
return agentTool(
name = "writeTool",
label = "Create Tool",
description = "Write a new Julia tool module file to src/tools/<name>.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"),
"label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"),
"description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"),
"inputSchema" => Dict(
"type" => "object",
"description" => "JSON Schema describing tool parameters in MCP format"
),
"executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."),
"validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."),
"prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."),
"parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools")
),
"required" => ["name", "label", "description", "inputSchema", "executeCode"]
),
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin
tool_name = get(args, "name", "")::String tool_name = get(args, "name", "")::String
tool_label = get(args, "label", tool_name)::String tool_label = get(args, "label", tool_name)::String
tool_description = get(args, "description", "")::String tool_description = get(args, "description", "")::String
@@ -167,8 +147,7 @@ function getTool()::agentTool
onPartialResult(Dict("status" => "Generating tool: $tool_name")) onPartialResult(Dict("status" => "Generating tool: $tool_name"))
# Build the tool file path # Build the tool file path
script_dir = dirname(@__FILE__) tools_dir = dirname(@__FILE__) # src/tools/
tools_dir = dirname(script_dir)
filepath = joinpath(tools_dir, "$(tool_name).jl") filepath = joinpath(tools_dir, "$(tool_name).jl")
# Check for naming conflicts # Check for naming conflicts
@@ -263,7 +242,34 @@ function getTool()::agentTool
), ),
nothing, false nothing, false
) )
end, end
"""
Define and return the writeTool agentTool.
"""
function getTool()::agentTool
return agentTool(
name = "writeTool",
label = "Create Tool",
description = "Write a new Julia tool module file to src/tools/<name>.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"),
"label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"),
"description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"),
"inputSchema" => Dict(
"type" => "object",
"description" => "JSON Schema describing tool parameters in MCP format"
),
"executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."),
"validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."),
"prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."),
"parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools")
),
"required" => ["name", "label", "description", "inputSchema", "executeCode"]
),
execute = executeTool,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = false parallelToolExecute = false
+3 -3
View File
@@ -267,6 +267,7 @@ struct agentTool # A tool available to the agent
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
_tool_module::Union{Module, Nothing} # Module where tool's executeTool is defined (for isolation)
end end
""" """
@@ -275,12 +276,11 @@ Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...
function agentTool(; name::String, label::String, description::String, inputSchema::Any, function agentTool(; name::String, label::String, description::String, inputSchema::Any,
execute::Function, prepareArguments::Union{Function, Nothing}=nothing, execute::Function, prepareArguments::Union{Function, Nothing}=nothing,
validateRequiredArgs::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing,
parallelToolExecute::Bool=false) parallelToolExecute::Bool=false, _tool_module::Union{Module,Nothing}=nothing)
return agentTool(name, label, description, inputSchema, execute, return agentTool(name, label, description, inputSchema, execute,
prepareArguments, validateRequiredArgs, parallelToolExecute) prepareArguments, validateRequiredArgs, parallelToolExecute, _tool_module)
end end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
# Agent context # # Agent context #
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
+50 -70
View File
@@ -3,7 +3,6 @@ using YiemAgent
using YiemAgent.toolRegistry using YiemAgent.toolRegistry
using YiemAgent.type using YiemAgent.type
# Path to the real tools directory
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools" begin @testset "loadTools" begin
@@ -15,95 +14,77 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 2. loadTools throws if a .jl file does not define getTool() # # 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() bad_dir = mktempdir()
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
@test_throws ArgumentError loadTools(bad_dir) @test_throws ArgumentError loadTools(bad_dir)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ # # 3. Load all tools from src/tools/ #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
clearTools()
loaded = loadTools(TOOLS_DIR) loaded = loadTools(TOOLS_DIR)
@test !isempty(loaded) @test !isempty(loaded)
@test length(loaded) == 3 @test length(loaded) == 3 # getTime.jl, getWeather.jl, writeTool.jl
names = [t.name for t in loaded]
@test "getTime" in names
@test "getWeather" in names
@test "writeTool" in names
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically by filename # # 4. Each loaded tool has an isolated _tool_module #
# (getTime.jl < getWeather.jl < writeTool.jl) #
# because 'T' < 'W' in ASCII #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@test loaded[1].name == "getTime" for tool in loaded
@test loaded[2].name == "getWeather" @test tool._tool_module isa Module
@test loaded[3].name == "writeTool" end
# Verify modules are unique (not shared)
modules = [t._tool_module for t in loaded]
@test length(unique(modules)) == length(modules)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. Verify loaded tool fields are correct # # 5. Each tool's executeTool is callable #
# ------------------------------------------------------------------ #
# getTime
time_tool = loaded[1]
@test time_tool.name == "getTime"
@test time_tool.label == "Time Lookup"
@test time_tool.validateRequiredArgs !== nothing
@test time_tool.parallelToolExecute == false
@test time_tool.inputSchema["required"] == Any[]
# getWeather
weather = loaded[2]
@test weather.name == "getWeather"
@test weather.label == "Weather Lookup"
@test weather.execute !== nothing
@test weather.parallelToolExecute == false
@test weather.inputSchema["required"] == ["city"]
# writeTool
wt = loaded[3]
@test wt.name == "writeTool"
@test wt.label == "Create Tool"
@test wt.execute !== nothing
@test "name" in wt.inputSchema["required"]
@test "executeCode" in wt.inputSchema["required"]
# ------------------------------------------------------------------ #
# 6. Tool execution returns valid results #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
function run_all_execution_tests(loaded)
sig = nothing sig = nothing
op = x -> x # no-op partial result callback op = x -> x # no-op partial result callback
# execute getTime for tool in loaded
result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op) @test tool.execute !== nothing
@test result_t isa agentToolResult @test tool._tool_module isa Module
@test result_t.content[1] isa textContent
@test occursin("Tokyo", result_t.content[1].text)
# execute getTime with timezone # Verify the module actually defines executeTool
result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op) @test isdefined(tool._tool_module, :executeTool)
@test result_tz isa agentToolResult
@test occursin("America/New_York", result_tz.content[1].text)
# execute getWeather # Execute the tool — must return agentToolResult without throwing
result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op) result = try
@test result_w isa agentToolResult tool.execute("call-$(tool.name)", Dict{String,Any}("city" => "Tokyo"), sig, op)
@test result_w.content[1] isa textContent catch err
@test occursin("Bangkok", result_w.content[1].text) tool.execute("call-$(tool.name)", Dict{String,Any}(), sig, op)
end
@test result isa agentToolResult
@test result.content[1] isa textContent
end
end
run_all_execution_tests(loaded)
# execute getWeather with units # ------------------------------------------------------------------ #
result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) # 6. Specific tool assertions based on known tool names #
@test occursin("72°F", result_w2.content[1].text) # ------------------------------------------------------------------ #
time_tool = filter(t -> t.name == "getTime", loaded)
@test !isempty(time_tool)
@test time_tool[1].validateRequiredArgs !== nothing
getWeather_tool = filter(t -> t.name == "getWeather", loaded)
@test !isempty(getWeather_tool)
@test getWeather_tool[1].inputSchema["required"] == ["city"]
write_tool = filter(t -> t.name == "writeTool", loaded)
@test !isempty(write_tool)
@test "name" in write_tool[1].inputSchema["required"]
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools # # 7. getTools / registerTool / clearTools #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
registry_tools = getTools() registry_tools = getTools()
@test !isempty(registry_tools) @test !isempty(registry_tools)
@test any(t -> t.name == "getTime", registry_tools) @test length(registry_tools) == 3 # only the loaded tools
@test any(t -> t.name == "getWeather", registry_tools)
clearTools() clearTools()
@test isempty(getTools()) @test isempty(getTools())
@@ -117,20 +98,19 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false), agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false),
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = true parallelToolExecute = true,
_tool_module = nothing
) )
registerTool(test_tool) registerTool(test_tool)
reg = getTools() reg = getTools()
@test any(t -> t.name == "manualTool", reg) @test any(t -> t.name == "manualTool", reg)
@test count(t -> t.name == "manualTool", reg) == 1 @test count(t -> t.name == "manualTool", reg) == 1
@test reg[1].parallelToolExecute == true @test reg[1].parallelToolExecute == true
@test reg[1]._tool_module === nothing
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 8. getTools returns deep copy (mutations don't affect registry) # # 8. getTools returns new vector (mutations don't affect registry) #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
copy1 = getTools() clearTools()
copy2 = getTools() @test isempty(getTools())
@test copy1 !== copy2
empty!(copy1)
@test !isempty(getTools())
end end