Compare commits

..

3 Commits

Author SHA1 Message Date
ton 189bc2efcf update 2026-08-10 09:43:35 +07:00
ton 92b3e4081f update 2026-08-09 22:50:02 +07:00
ton 750eff483b update 2026-08-09 22:08:04 +07:00
6 changed files with 130 additions and 98 deletions
+4 -8
View File
@@ -516,7 +516,7 @@ function prepareToolCall(
signal::Union{Nothing, abortSignal}, signal::Union{Nothing, abortSignal},
)::Union{preparedToolCall,immediateOutcome} )::Union{preparedToolCall,immediateOutcome}
tool = find(t -> t.name == toolCall.name, context.tools) tool = get(context.tools, toolCall.name, nothing)
if tool === nothing if tool === nothing
return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true)
end end
@@ -996,13 +996,9 @@ function executeToolCalls(
hasSequential = false hasSequential = false
for tc in toolCalls for tc in toolCalls
for t in context.tools t = get(context.tools, tc.name, nothing)
if t.name == tc.name && !t.parallelToolExecute if t !== nothing && !t.parallelToolExecute
hasSequential = true hasSequential = true
break
end
end
if hasSequential
break break
end end
end end
+20 -13
View File
@@ -36,6 +36,25 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
return nothing return nothing
end 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. Define and return the getTime agentTool.
""" """
@@ -52,19 +71,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
+17 -10
View File
@@ -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. Define and return the getWeather agentTool.
""" """
@@ -14,16 +30,7 @@ function getTool()::agentTool
), ),
"required" => ["city"] "required" => ["city"]
), ),
execute = (toolCallId, args, signal, onPartialResult) -> begin execute = executeTool,
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
+66 -44
View File
@@ -3,12 +3,15 @@ module toolRegistry
export loadTools, registerTool, getTools, clearTools export loadTools, registerTool, getTools, clearTools
using Dates using Dates
using JSON using JSON, DataStructures
using ..type using ..type
# Global registry — populated at runtime by loadTools() or registerTool() # Global registry — populated at runtime by loadTools() or registerTool()
const _registry = Vector{agentTool}() 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 # Auto-register the built-in listTools tool
function __init__() function __init__()
registerTool(_listTool()) 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 `getTool()::agentTool`. Files are sorted alphabetically so tool
registration order is deterministic. 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 # 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 ```julia
# src/tools/getWeather.jl # 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 function getTool()::agentTool
return agentTool( return agentTool(
name = "getWeather", 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 end
``` ```
@@ -95,43 +92,68 @@ end
# Errors # Errors
- Throws `ArgumentError` if a tool file does not define a `getTool` function - 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) if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir")) throw(ArgumentError("Tool directory does not exist: $dir"))
end end
tools = agentTool[] tools = OrderedDict{String, agentTool}()
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), 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
filepath = joinpath(dir, filename) filepath = joinpath(dir, filename)
println("[toolRegistry] Loading tool from: $filepath")
# Include the file in the current module scope so all types resolve # Derive a unique module name from the filename only (not full path).
# (agentTool, textContent, agentToolResult, etc. are all available) # e.g. "getWeather.jl" -> "_tool_getWeather"
include(filepath) mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => ""))
# Validate that getTool was defined (include() places it in current module scope) # Build the complete module as a string and eval the parsed code.
if !isdefined(@__MODULE__, :getTool) # Julia does not allow `module ... end` inside eval(quote ...),
throw(ArgumentError( # and constructing the module AST by hand is fragile.
"Tool file $(filepath) does not define a `getTool()` function. " * # Instead, we generate the full module source as a string,
"Each tool file must define: function getTool()::agentTool ... end" # 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 end
"""
mod = eval(Meta.parse(module_code))
# Call getTool() — it runs in current scope where types are visible # Call getTool() via Core.eval in the submodule's scope.
# Use invokelatest to handle world-age semantics after include() # This evaluates getTool() entirely within the new module's world,
tool = invokelatest(getTool) # completely avoiding world-age issues — no invokelatest needed.
if !(tool isa agentTool) # Note: all uses of `tool` must be inside the `try` block because
throw(ArgumentError( # Julia 1.12's SSA form doesn't track `tool` as definitely assigned
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" # 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)
tools[tool.name] = 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 end
push!(_registry, tool)
push!(tools, tool)
println("[toolRegistry] Loaded tool: $(tool.name)$(tool.label)")
end end
return tools return tools
+15 -15
View File
@@ -291,7 +291,7 @@ Snapshot of the agent's conversation context.
# Arguments # Arguments
- `systemPrompt::String`: System prompt for the agent - `systemPrompt::String`: System prompt for the agent
- `messages::Vector{agentMessage}`: Conversation messages - `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 # Returns
- A new `agentContext` instance - A new `agentContext` instance
@@ -299,7 +299,7 @@ Snapshot of the agent's conversation context.
struct agentContext # Snapshot of the agent's conversation context struct agentContext # Snapshot of the agent's conversation context
systemPrompt::String # System prompt for the agent systemPrompt::String # System prompt for the agent
messages::Vector{agentMessage} # Conversation messages messages::Vector{agentMessage} # Conversation messages
tools::Union{Vector{agentTool}, Nothing} # Available tools tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name
end end
@@ -308,9 +308,9 @@ end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
mutable struct agentState # Mutable runtime state of an agent 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 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 history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
messages::Vector{agentMessage} messages::Vector{agentMessage}
@@ -329,7 +329,7 @@ new state from external references.
# Arguments # Arguments
- `systemPrompt::String`: System prompt text - `systemPrompt::String`: System prompt text
- `model::llmModel`: LLM model to use (defaults to an unknown model) - `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) - `messages::Vector{agentMessage}`: Conversation messages (deep copied)
# Returns # Returns
@@ -338,13 +338,13 @@ new state from external references.
# Examples # Examples
```julia ```julia
julia> state = agentState(systemPrompt="You are a helpful assistant") 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( function agentState(
systemPrompt::String="", systemPrompt::String="",
model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[],
tools::Vector{agentTool}=agentTool[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[], messages::Vector{agentMessage}=agentMessage[],
) )
agentState( agentState(
@@ -395,13 +395,13 @@ end
Configuration for the agent tool execution loop. Configuration for the agent tool execution loop.
# Arguments # Arguments
- `tools::Vector{agentTool}`: Available tools - `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
- `toolExecution::String`: Execution mode — "sequential" or "parallel" - `toolExecution::String`: Execution mode — "sequential" or "parallel"
""" """
struct agentLoopConfig struct agentLoopConfig
tools::Vector{agentTool} tools::OrderedDict{String, agentTool}
beforeToolCall::Union{Function, Nothing} beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing}
toolExecution::String toolExecution::String
@@ -580,7 +580,7 @@ on `inputChannel` and `followUpChannel` channels concurrently.
# Keyword Arguments # Keyword Arguments
- `systemPrompt::String`: System prompt for the agent - `systemPrompt::String`: System prompt for the agent
- `model`: LLM model to use - `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) - `messages::Vector{agentMessage}`: Initial conversation messages (default: empty)
- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) - `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`)
- `llmCall::Function`: Function to invoke the LLM (required) - `llmCall::Function`: Function to invoke the LLM (required)
@@ -599,14 +599,14 @@ on `inputChannel` and `followUpChannel` channels concurrently.
# Examples # Examples
```julia ```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(...), ..., ...) yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...)
```
""" """
function yiemAgent( function yiemAgent(
; systemPrompt::String="You are helpful assistant.", ; systemPrompt::String="You are helpful assistant.",
model=nothing, model=nothing,
tools::Vector{agentTool}=agentTool[], tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[], messages::Vector{agentMessage}=agentMessage[],
prepareContext::Union{Function, Nothing}=nothing, prepareContext::Union{Function, Nothing}=nothing,
formatMsgForLLM::Function=defaultformatMsgForLLM, formatMsgForLLM::Function=defaultformatMsgForLLM,
+8 -8
View File
@@ -29,7 +29,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test !isempty(loaded) @test !isempty(loaded)
@test length(loaded) == 3 @test length(loaded) == 3
names = [t.name for t in loaded] names = [k for k in keys(loaded)]
@test "getTime" in names @test "getTime" in names
@test "getWeather" in names @test "getWeather" in names
@test "writeTool" in names @test "writeTool" in names
@@ -39,15 +39,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# (getTime.jl < getWeather.jl < writeTool.jl) # # (getTime.jl < getWeather.jl < writeTool.jl) #
# because 'T' < 'W' in ASCII # # because 'T' < 'W' in ASCII #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@test loaded[1].name == "getTime" @test collect(keys(loaded))[1] == "getTime"
@test loaded[2].name == "getWeather" @test collect(keys(loaded))[2] == "getWeather"
@test loaded[3].name == "writeTool" @test collect(keys(loaded))[3] == "writeTool"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. Verify loaded tool fields are correct # # 5. Verify loaded tool fields are correct #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# getTime # getTime
time_tool = loaded[1] time_tool = loaded["getTime"]
@test time_tool.name == "getTime" @test time_tool.name == "getTime"
@test time_tool.label == "Time Lookup" @test time_tool.label == "Time Lookup"
@test time_tool.validateRequiredArgs !== nothing @test time_tool.validateRequiredArgs !== nothing
@@ -55,7 +55,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test time_tool.inputSchema["required"] == Any[] @test time_tool.inputSchema["required"] == Any[]
# getWeather # getWeather
weather = loaded[2] weather = loaded["getWeather"]
@test weather.name == "getWeather" @test weather.name == "getWeather"
@test weather.label == "Weather Lookup" @test weather.label == "Weather Lookup"
@test weather.execute !== nothing @test weather.execute !== nothing
@@ -63,7 +63,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test weather.inputSchema["required"] == ["city"] @test weather.inputSchema["required"] == ["city"]
# writeTool # writeTool
wt = loaded[3] wt = loaded["writeTool"]
@test wt.name == "writeTool" @test wt.name == "writeTool"
@test wt.label == "Create Tool" @test wt.label == "Create Tool"
@test wt.execute !== nothing @test wt.execute !== nothing
@@ -123,7 +123,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 8. getTools returns deep copy (mutations don't affect registry) # # 8. getTools returns deep copy (mutations don't affect registry) #