From ed5415d92a41568eb9c1a6662bf9b66a2287b995 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 08:45:35 +0700 Subject: [PATCH] update --- src/tools/getTime.jl | 47 +++---- src/tools/getWeather.jl | 41 ++---- src/tools/registry.jl | 4 +- src/tools/writeTool.jl | 285 ++++++++++++++++++++-------------------- test/loadToolTest.jl | 195 +++++++++++---------------- 5 files changed, 243 insertions(+), 329 deletions(-) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 97636ab..0d6a467 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -36,35 +36,6 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end -""" -Execute the getTime tool. - -# Arguments -- `toolCallId::String`: Unique identifier for this tool call -- `args::Dict{String,Any}`: Parsed arguments from the LLM -- `signal::Union{Nothing,abortSignal}`: Optional abort signal -- `onPartialResult::Function`: Callback for streaming partial results - -# Returns -- `agentToolResult`: Result content with current time data -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - # Simulate time lookup — replace with actual timezone API call - if tz !== nothing - result = "Current time in $(tz): $(now())" - else - result = "Current time in $(city): $(now())" - end - - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), nothing, false - ) -end - """ Define and return the getTime agentTool. """ @@ -76,12 +47,24 @@ function getTool()::agentTool inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict( - "timezone" => Dict("type" => "string", "description", "IANA timezone, e.g. 'America/New_York'"), - "city" => Dict("type" => "string", "description", "City name as fallback") + "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"), + "city" => Dict("type" => "string", "description" => "City name as fallback") ), "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, validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 0411841..61a17f5 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -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. """ @@ -44,7 +14,16 @@ function getTool()::agentTool ), "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, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/src/tools/registry.jl b/src/tools/registry.jl index b828793..f7aa585 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -2,6 +2,8 @@ module toolRegistry export loadTools, registerTool, getTools, clearTools +using Dates +using JSON using ..type # Global registry — populated at runtime by loadTools() or registerTool() @@ -99,7 +101,7 @@ function loadTools(dir::String)::Vector{agentTool} end 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) for filename in jl_files diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index fb2b969..40c4d8c 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -45,151 +45,6 @@ function validateToolName(name::String)::Union{Nothing,String} return nothing 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. """ @@ -270,7 +125,145 @@ function getTool()::agentTool ), "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, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index d228efe..a6a974e 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -3,9 +3,8 @@ using YiemAgent using YiemAgent.toolRegistry using YiemAgent.type -# ------------------------------------------------------------------ # -# loadTools() unit tests # -# ------------------------------------------------------------------ # +# Path to the real tools directory +TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @testset "loadTools" begin @@ -16,138 +15,99 @@ using YiemAgent.type # ------------------------------------------------------------------ # # 2. loadTools throws if a .jl file does not define getTool() # - # Must run before any other loadTools call (getTool persists in # - # module scope after include()). # + # 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(bad_dir) # ------------------------------------------------------------------ # - # 3. loadTools loads tool files that define getTool() # + # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - tmpdir = mktempdir() - - # 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) + loaded = loadTools(TOOLS_DIR) @test !isempty(loaded) - @test length(loaded) >= 1 + @test length(loaded) == 3 names = [t.name for t in loaded] - @test "testEcho" in names - - # Check agentTool fields - 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 + @test "getTime" in names + @test "getWeather" in names + @test "writeTool" in names # ------------------------------------------------------------------ # - # 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() - - tool_a = """ -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" + @test loaded[1].name == "getTime" + @test loaded[2].name == "getWeather" + @test loaded[3].name == "writeTool" # ------------------------------------------------------------------ # - # 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() @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() - registry_after_clear = getTools() - @test isempty(registry_after_clear) + @test isempty(getTools()) - # ------------------------------------------------------------------ # - # 6. registerTool adds to global registry # - # ------------------------------------------------------------------ # test_tool = agentTool( name = "manualTool", label = "Manual Tool", @@ -163,13 +123,10 @@ end reg = getTools() @test any(t -> t.name == "manualTool", reg) @test count(t -> t.name == "manualTool", reg) == 1 - - # parallelToolExecute flag - manual_entry = filter(t -> t.name == "manualTool", reg) - @test manual_entry[1].parallelToolExecute == true + @test reg[1].parallelToolExecute == true # ------------------------------------------------------------------ # - # 7. getTools returns deep copy # + # 8. getTools returns deep copy (mutations don't affect registry) # # ------------------------------------------------------------------ # copy1 = getTools() copy2 = getTools()