Compare commits

..

7 Commits

Author SHA1 Message Date
ton 287704778f update 2026-08-10 20:07:15 +07:00
ton a9fa23f01b update 2026-08-10 19:19:58 +07:00
ton c5cb18f0f1 update 2026-08-10 16:10:04 +07:00
ton c78f4b023d update 2026-08-10 14:55:09 +07:00
ton 1b69f69c7d update 2026-08-10 13:33:45 +07:00
ton 3891099eaa update readme 2026-08-10 10:39:30 +07:00
ton 268d340e2f Merge pull request 'V0.8.0 use tool module' (#42) from v0.8.0-use_tool_module into v0.8.0
Reviewed-on: #42
2026-08-10 02:48:25 +00:00
7 changed files with 1717 additions and 527 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ module YiemAgent
include("utils.jl")
using .utils
include("tools/registry.jl")
include("toolRegistry.jl")
using .toolRegistry
# include("llmfunction.jl")
+109 -62
View File
@@ -1,27 +1,55 @@
module toolRegistry
export loadTools, registerTool, getTools, clearTools
export ToolStore, loadTools, registerTool, getTools, clearTools, listTool
using Dates
using JSON, DataStructures
using ..type
# Global registry — populated at runtime by loadTools() or registerTool()
const _registry = Vector{agentTool}()
"""
Per-agent isolated tool storage.
# Module references — kept alive to prevent GC of tool code that closures depend on
const _tool_modules = Vector{Module}()
Each agent gets its own `ToolStore` so tool registration is independent
`registerTool(store, tool)` only affects that agent's tool set.
# Auto-register the built-in listTools tool
function __init__()
registerTool(_listTool())
# Fields
- `tools::OrderedDict{String, agentTool}` keyed by name for O(1) lookup + ordered iteration
- `name::String` identifier for debugging/logs
"""
struct ToolStore
tools::OrderedDict{String, agentTool}
name::String
end
"""
Create a new isolated tool store.
# Keyword Arguments
- `name::String`: Identifier for this store (default: "default")
# Examples
```julia
store = ToolStore(name="agent1")
tools = loadTools(store, "src/tools")
registerTool(store, my_tool)
agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store)
```
"""
function ToolStore(; name::String="default")::ToolStore
ToolStore(OrderedDict{String, agentTool}(), name)
end
"""
List tool definition lets the agent query available tools for collision detection
when creating new tools via writeTool.
# Arguments
- `store::ToolStore`: The tool store to list from
Each `ToolStore` gets its own `listTool` instance bound to that store,
so each agent sees only its own tools.
"""
function _listTool()::agentTool
function listTool(store::ToolStore)::agentTool
return agentTool(
name = "listTools",
label = "List Tools",
@@ -32,11 +60,11 @@ function _listTool()::agentTool
"required" => Any[]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
tools = getTools()
tools = getTools(store)
if isempty(tools)
result_text = "No tools registered."
else
lines = String["- $(t.name): $(t.label)$(t.description)" for t in tools]
lines = String["- $(t.name): $(t.label)$(t.description)" for (k, t) in tools]
result_text = "Available tools:\n" * join(lines, "\n")
end
return agentToolResult(
@@ -52,52 +80,41 @@ function _listTool()::agentTool
end
"""
Load all tool modules from a directory.
Load all tool modules from a directory into a specific ToolStore.
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.
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`.
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",
...
)
end
```
# Arguments
- `store::ToolStore`: The tool store to register tools into
- `dir::String`: Directory path to scan for `.jl` tool files
# Returns
- `Vector{agentTool}`: All loaded tools
- `OrderedDict{String, agentTool}`: All loaded tools keyed by name
# Errors
- Throws `ArgumentError` if a tool file does not define a `getTool` function
# Examples
```julia
julia> store = ToolStore(name="agent1")
julia> tools = loadTools(store, "src/tools")
OrderedDict{String, agentTool} with 3 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"listTools" => agentTool(...)
```
"""
function loadTools(dir::String)::OrderedDict{String, agentTool}
function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool}
if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir"))
end
tools = OrderedDict{String, agentTool}()
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
sort!(jl_files)
@@ -113,13 +130,13 @@ function loadTools(dir::String)::OrderedDict{String, agentTool}
# 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).
# Each tool file declares its own dependencies via `using` statements
# at the top of the file — the registry only injects `using ..type`
# to make core types (agentTool, textContent, etc.) available.
file_content = read(filepath, String)
module_code = """
module $(mod_name)
using ..type
using Dates, UUIDs, DataStructures, JSON
$(file_content)
end
"""
@@ -138,13 +155,8 @@ function loadTools(dir::String)::OrderedDict{String, agentTool}
"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)")
store.tools[tool.name] = tool
println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))")
catch e
if e isa UndefVarError || occursin("getTool", sprint(showerror, e))
throw(ArgumentError(
@@ -156,40 +168,75 @@ function loadTools(dir::String)::OrderedDict{String, agentTool}
end
end
return tools
return store.tools
end
"""
Register a single agentTool into the global registry.
Register a single agentTool into a specific ToolStore.
# Arguments
- `store::ToolStore`: The tool store to register into
- `tool::agentTool`: The tool to register
# Returns
- `Vector{agentTool}`: Updated registry
- `OrderedDict{String, agentTool}`: Updated tool dict for this store
# Examples
```julia
julia> store = ToolStore(name="agent1")
julia> registerTool(store, my_tool)
[toolRegistry:agent1] Registered tool: my_tool
```
"""
function registerTool(tool::agentTool)::Vector{agentTool}
push!(_registry, tool)
println("[toolRegistry] Registered tool: $(tool.name)")
return _registry
function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool}
store.tools[tool.name] = tool
println("[$(store.name)] Registered tool: $(tool.name)")
return store.tools
end
"""
Get all registered tools.
Get the registered tools from a specific ToolStore.
Returns the internal `OrderedDict` directly O(1) lookup by name,
ordered iteration preserving registration order.
# Arguments
- `store::ToolStore`: The tool store to query
# Returns
- `Vector{agentTool}`: Copy of the registry
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
# Examples
```julia
julia> getTools(store)
OrderedDict{String, agentTool} with 3 entries:
"listTools" => agentTool(...)
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
```
"""
function getTools()::Vector{agentTool}
return deepcopy(_registry)
function getTools(store::ToolStore)::OrderedDict{String, agentTool}
return store.tools
end
"""
Clear all registered tools from the global registry.
Clear all registered tools from a specific ToolStore.
# Arguments
- `store::ToolStore`: The tool store to clear
# Returns
- `nothing`
# Examples
```julia
julia> clearTools(store)
[toolRegistry:agent1] Registry cleared
```
"""
function clearTools()::Nothing
empty!(_registry)
println("[toolRegistry] Registry cleared")
function clearTools(store::ToolStore)::Nothing
empty!(store.tools)
println("[$(store.name)] Registry cleared")
return nothing
end
+1531 -434
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
using Dates
"""
Validate required arguments for the getTime tool.
+5 -3
View File
@@ -1,3 +1,5 @@
using JSON
"""
Tool that writes new Julia tool module files to disk.
@@ -5,7 +7,7 @@ The agent can use this tool when it encounters a task that no existing tool
can handle. Provide the tool's name, label, description, inputSchema, and
execute logic as Julia code. The tool is written to `src/tools/<name>.jl`.
After calling this tool, restart the agent so `loadTools("src/tools")` picks
After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks
up the new file. The new tool is immediately available.
# Example
@@ -248,13 +250,13 @@ function getTool()::agentTool
tool_code = join(parts)
# Write the file — tool is loaded on next agent restart via loadTools()
# Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools")
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.")],
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")],
Dict{Any,Any}(
"file" => filepath,
"name" => tool_name,
+11 -6
View File
@@ -567,9 +567,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper
# prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context
sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function
end
parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function
_tool_store::Any # Reference to the ToolStore for runtime registration
end
"""
Create a new yiemAgent instance with a background loop task.
@@ -593,15 +594,17 @@ on `inputChannel` and `followUpChannel` channels concurrently.
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events
- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`)
# Returns
- A new `yiemAgent` instance with an active background task
# Examples
```julia
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(...), ..., ...)
julia> store = ToolStore(name="agent1")
julia> tools = loadTools(store, "src/tools")
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store)
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store)
"""
function yiemAgent(
; systemPrompt::String="You are helpful assistant.",
@@ -619,6 +622,7 @@ function yiemAgent(
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false,
agentEventSink::Function,
tool_store::Union{Any, Nothing}=nothing,
)
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16)
@@ -643,6 +647,7 @@ function yiemAgent(
maxRetryDelayMs,
parallelToolExecute,
agentEventSink,
tool_store,
)
# Spawn the background loop and attach it
+58 -21
View File
@@ -6,12 +6,13 @@ using YiemAgent.type
# Path to the real tools directory
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools" begin
@testset "loadTools with ToolStore" begin
# ------------------------------------------------------------------ #
# 1. loadTools throws on non-existent directory #
# ------------------------------------------------------------------ #
@test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist")
store = ToolStore(name="test1")
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist")
# ------------------------------------------------------------------ #
# 2. loadTools throws if a .jl file does not define getTool() #
@@ -20,12 +21,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ #
bad_dir = mktempdir()
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
@test_throws ArgumentError loadTools(bad_dir)
@test_throws ArgumentError loadTools(store, bad_dir)
# ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ #
# ------------------------------------------------------------------ #
loaded = loadTools(TOOLS_DIR)
store2 = ToolStore(name="test2")
loaded = loadTools(store2, TOOLS_DIR)
@test !isempty(loaded)
@test length(loaded) == 3
@@ -98,15 +100,29 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test occursin("72°F", result_w2.content[1].text)
# ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools #
# 7. getTools / registerTool / clearTools (per-store isolation) #
# ------------------------------------------------------------------ #
registry_tools = getTools()
@test !isempty(registry_tools)
@test any(t -> t.name == "getTime", registry_tools)
@test any(t -> t.name == "getWeather", registry_tools)
store3 = ToolStore(name="test3")
registry_tools = getTools(store3)
@test isempty(registry_tools)
clearTools()
@test isempty(getTools())
# listTool is not auto-registered anymore — each store starts empty
# Register tools manually
registerTool(store3, loaded["getTime"])
registerTool(store3, loaded["getWeather"])
registerTool(store3, loaded["writeTool"])
reg = getTools(store3)
@test !isempty(reg)
@test "getTime" in keys(reg)
@test "getWeather" in keys(reg)
@test "writeTool" in keys(reg)
@test collect(keys(reg))[1] == "getTime"
@test collect(keys(reg))[2] == "getWeather"
@test collect(keys(reg))[3] == "writeTool"
clearTools(store3)
@test isempty(getTools(store3))
test_tool = agentTool(
name = "manualTool",
@@ -119,18 +135,39 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
validateRequiredArgs = nothing,
parallelToolExecute = true
)
registerTool(test_tool)
reg = getTools()
@test any(t -> t.name == "manualTool", reg)
@test count(t -> t.name == "manualTool", reg) == 1
@test reg[1].parallelToolExecute == true
registerTool(store3, test_tool)
reg = getTools(store3)
@test haskey(reg, "manualTool")
@test length(reg) == 1
@test reg["manualTool"].parallelToolExecute == true
# ------------------------------------------------------------------ #
# 8. getTools returns deep copy (mutations don't affect registry) #
# 8. getTools returns direct reference (mutations affect registry) #
# ------------------------------------------------------------------ #
copy1 = getTools()
copy2 = getTools()
@test copy1 !== copy2
copy1 = getTools(store3)
copy2 = getTools(store3)
@test copy1 === copy2 # same reference, not a deep copy
empty!(copy1)
@test !isempty(getTools())
@test isempty(getTools(store3)) # mutation propagates
# ------------------------------------------------------------------ #
# 9. Per-store isolation — two stores don't share tools #
# ------------------------------------------------------------------ #
storeA = ToolStore(name="isolationA")
storeB = ToolStore(name="isolationB")
registerTool(storeA, loaded["getTime"])
registerTool(storeB, loaded["getWeather"])
regA = getTools(storeA)
regB = getTools(storeB)
@test "getTime" in keys(regA)
@test "getWeather" keys(regA)
@test "getWeather" in keys(regB)
@test "getTime" keys(regB)
clearTools(storeA)
@test isempty(getTools(storeA))
@test !isempty(getTools(storeB)) # storeB unaffected
end