This commit is contained in:
2026-08-09 08:45:35 +07:00
parent 6b3d575ea0
commit ed5415d92a
5 changed files with 243 additions and 329 deletions
+15 -32
View File
@@ -36,35 +36,6 @@ 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.
""" """
@@ -76,12 +47,24 @@ function getTool()::agentTool
inputSchema = Dict{String,Any}( inputSchema = Dict{String,Any}(
"type" => "object", "type" => "object",
"properties" => Dict( "properties" => Dict(
"timezone" => Dict("type" => "string", "description", "IANA timezone, e.g. 'America/New_York'"), "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"),
"city" => Dict("type" => "string", "description", "City name as fallback") "city" => Dict("type" => "string", "description" => "City name as fallback")
), ),
"required" => [] "required" => []
), ),
execute = executeTool, execute = (toolCallId, args, signal, onPartialResult) -> begin
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
+10 -31
View File
@@ -1,33 +1,3 @@
"""
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.
""" """
@@ -44,7 +14,16 @@ function getTool()::agentTool
), ),
"required" => ["city"] "required" => ["city"]
), ),
execute = executeTool, # reference the function defined above execute = (toolCallId, args, signal, onPartialResult) -> begin
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
+3 -1
View File
@@ -2,6 +2,8 @@ module toolRegistry
export loadTools, registerTool, getTools, clearTools export loadTools, registerTool, getTools, clearTools
using Dates
using JSON
using ..type using ..type
# Global registry — populated at runtime by loadTools() or registerTool() # Global registry — populated at runtime by loadTools() or registerTool()
@@ -99,7 +101,7 @@ function loadTools(dir::String)::Vector{agentTool}
end end
tools = agentTool[] tools = agentTool[]
jl_files = filter(f -> endswith(f, ".jl"), readdir(dir)) jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
sort!(jl_files) sort!(jl_files)
for filename in jl_files for filename in jl_files
+139 -146
View File
@@ -45,151 +45,6 @@ function validateToolName(name::String)::Union{Nothing,String}
return nothing return nothing
end end
"""
Execute the writeTool.
Generates a new .jl tool file and registers it with the tool registry.
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
tool_name = get(args, "name", "")::String
tool_label = get(args, "label", tool_name)::String
tool_description = get(args, "description", "")::String
tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any}
execute_code = get(args, "executeCode", "")::String
validate_code = get(args, "validateCode", nothing)::Union{String,Nothing}
prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing}
parallel = get(args, "parallel", false)::Bool
# Validate tool name
name_err = validateToolName(tool_name)
if name_err !== nothing
return agentToolResult(
[textContent(name_err)],
Dict{Any,Any}(), nothing, false
)
end
# Validate required fields
if isempty(tool_name)
return agentToolResult(
[textContent("Missing required field: 'name'")],
Dict{Any,Any}(), nothing, false
)
end
if isempty(tool_description)
return agentToolResult(
[textContent("Missing required field: 'description'")],
Dict{Any,Any}(), nothing, false
)
end
if isempty(execute_code)
return agentToolResult(
[textContent("Missing required field: 'executeCode'")],
Dict{Any,Any}(), nothing, false
)
end
onPartialResult(Dict("status" => "Generating tool: $tool_name"))
# Build the tool file path
script_dir = dirname(@__FILE__)
tools_dir = dirname(script_dir)
filepath = joinpath(tools_dir, "$(tool_name).jl")
# Check for naming conflicts
if isfile(filepath)
return agentToolResult(
[textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")],
Dict{Any,Any}(), nothing, false
)
end
onPartialResult(Dict("status" => "Writing file: $(basename(filepath))"))
# Convert schema Dict to a Julia Dict literal string
schema_literal = dict_to_julia_literal(tool_schema)
# Build optional validation function
validate_section = if validate_code !== nothing && !isempty(validate_code)
indented = indent_code(validate_code, 4)
"function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n"
else
""
end
# Build optional prepare function
prepare_section = if prepare_code !== nothing && !isempty(prepare_code)
indented = indent_code(prepare_code, 4)
"function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n"
else
""
end
# Indent user's execute code for embedding inside execute function body
indented_exec = indent_code(execute_code, 4)
# Escape description for Julia string literal
escaped_desc = replace(tool_description, "\\" => "\\\\")
escaped_desc = replace(escaped_desc, "\"" => "\\\"")
# Build the complete tool file content
parts = String[]
push!(parts, "# Auto-generated tool: $tool_name\n")
push!(parts, "# Generated by writeTool at $(now())\n\n")
if !isempty(validate_section)
push!(parts, validate_section)
push!(parts, "\n")
end
if !isempty(prepare_section)
push!(parts, prepare_section)
push!(parts, "\n")
end
push!(parts, "\n")
push!(parts, "# Execute function\n")
push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n")
push!(parts, "$indented_exec\n")
push!(parts, "end\n\n")
push!(parts, "# Tool definition\n")
push!(parts, "function getTool()::agentTool\n")
push!(parts, " return agentTool(\n")
push!(parts, " name = \"$(tool_name)\",\n")
push!(parts, " label = \"$(tool_label)\",\n")
push!(parts, " description = \"$(escaped_desc)\",\n")
push!(parts, " inputSchema = $schema_literal,\n")
push!(parts, " execute = executeTool,\n")
if validate_code !== nothing && !isempty(validate_code)
push!(parts, " validateRequiredArgs = validateRequiredArgs,\n")
else
push!(parts, " validateRequiredArgs = nothing,\n")
end
if prepare_code !== nothing && !isempty(prepare_code)
push!(parts, " prepareArguments = prepareArguments,\n")
else
push!(parts, " prepareArguments = nothing,\n")
end
push!(parts, " parallelToolExecute = $parallel\n")
push!(parts, " )\n")
push!(parts, "end\n")
tool_code = join(parts)
# Write the file — tool is loaded on next agent restart via loadTools()
write(filepath, tool_code)
onPartialResult(Dict("status" => "Done"))
return agentToolResult(
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")],
Dict{Any,Any}(
"file" => filepath,
"name" => tool_name,
"label" => tool_label,
"description" => tool_description,
),
nothing, false
)
end
""" """
Indent a multi-line code string by the specified number of spaces. Indent a multi-line code string by the specified number of spaces.
""" """
@@ -270,7 +125,145 @@ function getTool()::agentTool
), ),
"required" => ["name", "label", "description", "inputSchema", "executeCode"] "required" => ["name", "label", "description", "inputSchema", "executeCode"]
), ),
execute = executeTool, execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin
tool_name = get(args, "name", "")::String
tool_label = get(args, "label", tool_name)::String
tool_description = get(args, "description", "")::String
tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any}
execute_code = get(args, "executeCode", "")::String
validate_code = get(args, "validateCode", nothing)::Union{String,Nothing}
prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing}
parallel = get(args, "parallel", false)::Bool
# Validate tool name
name_err = validateToolName(tool_name)
if name_err !== nothing
return agentToolResult(
[textContent(name_err)],
Dict{Any,Any}(), nothing, false
)
end
# Validate required fields
if isempty(tool_name)
return agentToolResult(
[textContent("Missing required field: 'name'")],
Dict{Any,Any}(), nothing, false
)
end
if isempty(tool_description)
return agentToolResult(
[textContent("Missing required field: 'description'")],
Dict{Any,Any}(), nothing, false
)
end
if isempty(execute_code)
return agentToolResult(
[textContent("Missing required field: 'executeCode'")],
Dict{Any,Any}(), nothing, false
)
end
onPartialResult(Dict("status" => "Generating tool: $tool_name"))
# Build the tool file path
script_dir = dirname(@__FILE__)
tools_dir = dirname(script_dir)
filepath = joinpath(tools_dir, "$(tool_name).jl")
# Check for naming conflicts
if isfile(filepath)
return agentToolResult(
[textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")],
Dict{Any,Any}(), nothing, false
)
end
onPartialResult(Dict("status" => "Writing file: $(basename(filepath))"))
# Convert schema Dict to a Julia Dict literal string
schema_literal = dict_to_julia_literal(tool_schema)
# Build optional validation function
validate_section = if validate_code !== nothing && !isempty(validate_code)
indented = indent_code(validate_code, 4)
"function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n"
else
""
end
# Build optional prepare function
prepare_section = if prepare_code !== nothing && !isempty(prepare_code)
indented = indent_code(prepare_code, 4)
"function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n"
else
""
end
# Indent user's execute code for embedding inside execute function body
indented_exec = indent_code(execute_code, 4)
# Escape description for Julia string literal
escaped_desc = replace(tool_description, "\\" => "\\\\")
escaped_desc = replace(escaped_desc, "\"" => "\\\"")
# Build the complete tool file content
parts = String[]
push!(parts, "# Auto-generated tool: $tool_name\n")
push!(parts, "# Generated by writeTool at $(now())\n\n")
if !isempty(validate_section)
push!(parts, validate_section)
push!(parts, "\n")
end
if !isempty(prepare_section)
push!(parts, prepare_section)
push!(parts, "\n")
end
push!(parts, "\n")
push!(parts, "# Execute function\n")
push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n")
push!(parts, "$indented_exec\n")
push!(parts, "end\n\n")
push!(parts, "# Tool definition\n")
push!(parts, "function getTool()::agentTool\n")
push!(parts, " return agentTool(\n")
push!(parts, " name = \"$(tool_name)\",\n")
push!(parts, " label = \"$(tool_label)\",\n")
push!(parts, " description = \"$(escaped_desc)\",\n")
push!(parts, " inputSchema = $schema_literal,\n")
push!(parts, " execute = executeTool,\n")
if validate_code !== nothing && !isempty(validate_code)
push!(parts, " validateRequiredArgs = validateRequiredArgs,\n")
else
push!(parts, " validateRequiredArgs = nothing,\n")
end
if prepare_code !== nothing && !isempty(prepare_code)
push!(parts, " prepareArguments = prepareArguments,\n")
else
push!(parts, " prepareArguments = nothing,\n")
end
push!(parts, " parallelToolExecute = $parallel\n")
push!(parts, " )\n")
push!(parts, "end\n")
tool_code = join(parts)
# Write the file — tool is loaded on next agent restart via loadTools()
write(filepath, tool_code)
onPartialResult(Dict("status" => "Done"))
return agentToolResult(
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")],
Dict{Any,Any}(
"file" => filepath,
"name" => tool_name,
"label" => tool_label,
"description" => tool_description,
),
nothing, false
)
end,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = false parallelToolExecute = false
+76 -119
View File
@@ -3,9 +3,8 @@ using YiemAgent
using YiemAgent.toolRegistry using YiemAgent.toolRegistry
using YiemAgent.type using YiemAgent.type
# ------------------------------------------------------------------ # # Path to the real tools directory
# loadTools() unit tests # TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ #
@testset "loadTools" begin @testset "loadTools" begin
@@ -16,138 +15,99 @@ using YiemAgent.type
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 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 persists in # # Must run BEFORE any other loadTools call (getTool binding #
# module scope after include()). # # 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 tool files that define getTool() # # 3. loadTools loads actual tool files from src/tools/ #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
tmpdir = mktempdir() loaded = loadTools(TOOLS_DIR)
# Create a valid tool file (must use bare type names — include() places file in toolRegistry scope)
valid_tool_echo = """
function getTool()::agentTool
return agentTool(
name = "testEcho",
label = "Echo Test",
description = "Echoes the input argument",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict("message" => Dict("type" => "string")),
"required" => Any["message"]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
return agentToolResult(
[textContent("echo: " * string(args["message"]))],
Dict{Any,Any}(), nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
"""
write(joinpath(tmpdir, "getEcho.jl"), valid_tool_echo)
loaded = loadTools(tmpdir)
@test !isempty(loaded) @test !isempty(loaded)
@test length(loaded) >= 1 @test length(loaded) == 3
names = [t.name for t in loaded] names = [t.name for t in loaded]
@test "testEcho" in names @test "getTime" in names
@test "getWeather" in names
# Check agentTool fields @test "writeTool" in names
echo_tool = filter(t -> t.name == "testEcho", loaded)
@test !isempty(echo_tool)
@test echo_tool[1].label == "Echo Test"
@test echo_tool[1].description == "Echoes the input argument"
@test echo_tool[1].parallelToolExecute == false
@test echo_tool[1].execute !== nothing
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically # # 4. loadTools returns tools sorted alphabetically by filename #
# (getTime.jl < getWeather.jl < writeTool.jl) #
# because 'T' < 'W' in ASCII #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
sorted_dir = mktempdir() @test loaded[1].name == "getTime"
@test loaded[2].name == "getWeather"
tool_a = """ @test loaded[3].name == "writeTool"
function getTool()::agentTool
return agentTool(
name = "alphaTool",
label = "Alpha Tool",
description = "First tool",
inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]),
execute = (toolCallId, args, signal, onPartialResult) ->
agentToolResult([textContent("alpha")], Dict{Any,Any}(), nothing, false),
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
"""
tool_m = """
function getTool()::agentTool
return agentTool(
name = "midTool",
label = "Mid Tool",
description = "Middle tool",
inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]),
execute = (toolCallId, args, signal, onPartialResult) ->
agentToolResult([textContent("mid")], Dict{Any,Any}(), nothing, false),
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
"""
tool_z = """
function getTool()::agentTool
return agentTool(
name = "zuluTool",
label = "Zulu Tool",
description = "Last tool",
inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]),
execute = (toolCallId, args, signal, onPartialResult) ->
agentToolResult([textContent("zulu")], Dict{Any,Any}(), nothing, false),
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
"""
write(joinpath(sorted_dir, "zTool.jl"), tool_z)
write(joinpath(sorted_dir, "aTool.jl"), tool_a)
write(joinpath(sorted_dir, "mTool.jl"), tool_m)
loaded_sorted = loadTools(sorted_dir)
# loadTools returns only tools loaded from the directory, in file-sorted order
@test length(loaded_sorted) == 3
@test loaded_sorted[1].name == "alphaTool"
@test loaded_sorted[2].name == "midTool"
@test loaded_sorted[3].name == "zuluTool"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. getTools returns a deep copy (mutations don't affect registry) # # 5. Verify loaded tool fields are correct #
# ------------------------------------------------------------------ #
# 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 #
# ------------------------------------------------------------------ #
sig = nothing
op = x -> x # no-op partial result callback
# execute getTime
result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op)
@test result_t isa agentToolResult
@test result_t.content[1] isa textContent
@test occursin("Tokyo", result_t.content[1].text)
# execute getTime with timezone
result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op)
@test result_tz isa agentToolResult
@test occursin("America/New_York", result_tz.content[1].text)
# execute getWeather
result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op)
@test result_w isa agentToolResult
@test result_w.content[1] isa textContent
@test occursin("Bangkok", result_w.content[1].text)
# execute getWeather with units
result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op)
@test occursin("72°F", result_w2.content[1].text)
# ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
registry_tools = getTools() registry_tools = getTools()
@test !isempty(registry_tools) @test !isempty(registry_tools)
orig_count = length(registry_tools) @test any(t -> t.name == "getTime", registry_tools)
@test any(t -> t.name == "getWeather", registry_tools)
# Clear and add a new tool via registerTool
clearTools() clearTools()
registry_after_clear = getTools() @test isempty(getTools())
@test isempty(registry_after_clear)
# ------------------------------------------------------------------ #
# 6. registerTool adds to global registry #
# ------------------------------------------------------------------ #
test_tool = agentTool( test_tool = agentTool(
name = "manualTool", name = "manualTool",
label = "Manual Tool", label = "Manual Tool",
@@ -163,13 +123,10 @@ end
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
# parallelToolExecute flag
manual_entry = filter(t -> t.name == "manualTool", reg)
@test manual_entry[1].parallelToolExecute == true
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 7. getTools returns deep copy # # 8. getTools returns deep copy (mutations don't affect registry) #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
copy1 = getTools() copy1 = getTools()
copy2 = getTools() copy2 = getTools()