From 750eff483bd71e4a7db8d3ae9429026648396e00 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 22:08:04 +0700 Subject: [PATCH 1/3] update --- src/tools/registry.jl | 104 +++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/src/tools/registry.jl b/src/tools/registry.jl index f7aa585..4cc0a01 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -9,6 +9,9 @@ using ..type # Global registry — populated at runtime by loadTools() or registerTool() const _registry = Vector{agentTool}() +# Module references — kept alive to prevent GC of tool code that closures depend on +const _tool_modules = Vector{Module}() + # Auto-register the built-in listTools tool function __init__() registerTool(_listTool()) @@ -55,33 +58,27 @@ 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. + # Tool file format -Each `.jl` file defines one function `getTool()` that returns an `agentTool`: +Each `.jl` file defines one function `getTool()` that returns an `agentTool`. +Inside the file you can freely define as many helper functions as you need — +they will all be scoped under the tool's submodule. ```julia # src/tools/getWeather.jl + +# These are namespaced — no collision with getTime.validateRequiredArgs, etc. +function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} + ... +end + 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 ``` @@ -106,32 +103,57 @@ function loadTools(dir::String)::Vector{agentTool} for filename in jl_files filepath = joinpath(dir, filename) - println("[toolRegistry] Loading tool from: $filepath") - # Include the file in the current module scope so all types resolve - # (agentTool, textContent, agentToolResult, etc. are all available) - include(filepath) + # 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" => "")) - # Validate that getTool was defined (include() places it in current module scope) - if !isdefined(@__MODULE__, :getTool) - throw(ArgumentError( - "Tool file $(filepath) does not define a `getTool()` function. " * - "Each tool file must define: function getTool()::agentTool ... end" - )) + # 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. + # Also import Dates, UUIDs, DataStructures, JSON — common dependencies + # that tool files use (and that the ..type module transitively uses). + file_content = read(filepath, String) + module_code = """ + module $(mod_name) + using ..type + using Dates, UUIDs, DataStructures, JSON + $(file_content) end + """ + mod = eval(Meta.parse(module_code)) - # Call getTool() — it runs in current scope where types are visible - # Use invokelatest to handle world-age semantics after include() - tool = invokelatest(getTool) - if !(tool isa agentTool) - throw(ArgumentError( - "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" - )) + # 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 + # Keep module reference alive — closures in the agentTool (execute, + # validateRequiredArgs, prepareArguments) may reference module-scoped + # functions. Without this, GC could collect the module. + push!(_tool_modules, mod) + push!(_registry, tool) + push!(tools, tool) + println("[toolRegistry] 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 - - push!(_registry, tool) - push!(tools, tool) - println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") end return tools -- 2.52.0 From 92b3e4081f5e0a5c11fd647d06639f2c7ac515e3 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 22:50:02 +0700 Subject: [PATCH 2/3] update --- src/tools/getTime.jl | 33 ++++++++++++++++++++------------- src/tools/getWeather.jl | 27 +++++++++++++++++---------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 0d6a467..b58a752 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -36,6 +36,25 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end +""" +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 + 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 + """ Define and return the getTime agentTool. """ @@ -52,19 +71,7 @@ function getTool()::agentTool ), "required" => [] ), - 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, + execute = executeTool, prepareArguments = nothing, validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 61a17f5..e98d6a9 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,3 +1,19 @@ +""" +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 + ) +end + """ Define and return the getWeather agentTool. """ @@ -14,16 +30,7 @@ function getTool()::agentTool ), "required" => ["city"] ), - 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, + execute = executeTool, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false -- 2.52.0 From 189bc2efcfe98165d8abed1c22093bae536539a2 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 09:43:35 +0700 Subject: [PATCH 3/3] update --- src/agentCore.jl | 12 ++++-------- src/tools/registry.jl | 8 ++++---- src/type.jl | 30 +++++++++++++++--------------- test/loadToolTest.jl | 16 ++++++++-------- 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 852d145..b854d18 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -516,7 +516,7 @@ function prepareToolCall( signal::Union{Nothing, abortSignal}, )::Union{preparedToolCall,immediateOutcome} - tool = find(t -> t.name == toolCall.name, context.tools) + tool = get(context.tools, toolCall.name, nothing) if tool === nothing return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end @@ -996,13 +996,9 @@ function executeToolCalls( hasSequential = false for tc in toolCalls - for t in context.tools - if t.name == tc.name && !t.parallelToolExecute - hasSequential = true - break - end - end - if hasSequential + t = get(context.tools, tc.name, nothing) + if t !== nothing && !t.parallelToolExecute + hasSequential = true break end end diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 4cc0a01..3954e8b 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -3,7 +3,7 @@ module toolRegistry export loadTools, registerTool, getTools, clearTools using Dates -using JSON +using JSON, DataStructures using ..type # Global registry — populated at runtime by loadTools() or registerTool() @@ -92,12 +92,12 @@ end # Errors - Throws `ArgumentError` if a tool file does not define a `getTool` function """ -function loadTools(dir::String)::Vector{agentTool} +function loadTools(dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end - tools = agentTool[] + tools = OrderedDict{String, agentTool}() jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) sort!(jl_files) @@ -143,7 +143,7 @@ function loadTools(dir::String)::Vector{agentTool} # functions. Without this, GC could collect the module. push!(_tool_modules, mod) push!(_registry, tool) - push!(tools, tool) + tools[tool.name] = tool println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") catch e if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) diff --git a/src/type.jl b/src/type.jl index 7078168..ee373cf 100644 --- a/src/type.jl +++ b/src/type.jl @@ -291,7 +291,7 @@ Snapshot of the agent's conversation context. # Arguments - `systemPrompt::String`: System prompt for the agent - `messages::Vector{agentMessage}`: Conversation messages -- `tools::Union{Vector{agentTool}, Nothing}`: Available tools +- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup # Returns - A new `agentContext` instance @@ -299,7 +299,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{Vector{agentTool}, Nothing} # Available tools + tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name end @@ -308,9 +308,9 @@ end # ------------------------------------------------------------------------------------------------ # mutable struct agentState # Mutable runtime state of an agent - systemPrompt::String # System prompt text + systemPrompt::String # System prompt for the agent model::llmModel # LLM model to use - tools::Vector{agentTool} # Available tools + tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt messages::Vector{agentMessage} @@ -329,7 +329,7 @@ new state from external references. # Arguments - `systemPrompt::String`: System prompt text - `model::llmModel`: LLM model to use (defaults to an unknown model) -- `tools::Vector{agentTool}`: Available tools (deep copied) +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (deep copied) - `messages::Vector{agentMessage}`: Conversation messages (deep copied) # Returns @@ -338,13 +338,13 @@ new state from external references. # Examples ```julia julia> state = agentState(systemPrompt="You are a helpful assistant") -agentState("You are a helpful assistant", ..., agentTool[], agentMessage[], String[], nothing) -``` +agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing) """ function agentState( systemPrompt::String="", - model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), - tools::Vector{agentTool}=agentTool[], + 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[], ) agentState( @@ -395,13 +395,13 @@ end Configuration for the agent tool execution loop. # Arguments -- `tools::Vector{agentTool}`: Available tools +- `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::Vector{agentTool} + tools::OrderedDict{String, agentTool} beforeToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing} toolExecution::String @@ -580,7 +580,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. # Keyword Arguments - `systemPrompt::String`: System prompt for the agent - `model`: LLM model to use -- `tools::Vector{agentTool}`: Available tools (default: empty) +- `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) @@ -599,14 +599,14 @@ on `inputChannel` and `followUpChannel` channels concurrently. # Examples ```julia -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model) +julia> tools = loadTools("src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=...) yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) -``` """ function yiemAgent( ; systemPrompt::String="You are helpful assistant.", model=nothing, - tools::Vector{agentTool}=agentTool[], + tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), messages::Vector{agentMessage}=agentMessage[], prepareContext::Union{Function, Nothing}=nothing, formatMsgForLLM::Function=defaultformatMsgForLLM, diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index a6a974e..ad6613a 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -29,7 +29,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test !isempty(loaded) @test length(loaded) == 3 - names = [t.name for t in loaded] + names = [k for k in keys(loaded)] @test "getTime" in names @test "getWeather" in names @test "writeTool" in names @@ -39,15 +39,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # (getTime.jl < getWeather.jl < writeTool.jl) # # because 'T' < 'W' in ASCII # # ------------------------------------------------------------------ # - @test loaded[1].name == "getTime" - @test loaded[2].name == "getWeather" - @test loaded[3].name == "writeTool" + @test collect(keys(loaded))[1] == "getTime" + @test collect(keys(loaded))[2] == "getWeather" + @test collect(keys(loaded))[3] == "writeTool" # ------------------------------------------------------------------ # # 5. Verify loaded tool fields are correct # # ------------------------------------------------------------------ # # getTime - time_tool = loaded[1] + time_tool = loaded["getTime"] @test time_tool.name == "getTime" @test time_tool.label == "Time Lookup" @test time_tool.validateRequiredArgs !== nothing @@ -55,7 +55,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test time_tool.inputSchema["required"] == Any[] # getWeather - weather = loaded[2] + weather = loaded["getWeather"] @test weather.name == "getWeather" @test weather.label == "Weather Lookup" @test weather.execute !== nothing @@ -63,7 +63,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test weather.inputSchema["required"] == ["city"] # writeTool - wt = loaded[3] + wt = loaded["writeTool"] @test wt.name == "writeTool" @test wt.label == "Create Tool" @test wt.execute !== nothing @@ -123,7 +123,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") reg = getTools() @test any(t -> t.name == "manualTool", reg) @test count(t -> t.name == "manualTool", reg) == 1 - @test reg[1].parallelToolExecute == true + @test reg[1].parallelToolExecute == true # ------------------------------------------------------------------ # # 8. getTools returns deep copy (mutations don't affect registry) # -- 2.52.0