V0.8.0 verify tool use #43

Merged
ton merged 6 commits from v0.8.0-verify_tool_use into v0.8.0 2026-08-10 13:10:57 +00:00
7 changed files with 1717 additions and 527 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ module YiemAgent
include("utils.jl") include("utils.jl")
using .utils using .utils
include("tools/registry.jl") include("toolRegistry.jl")
using .toolRegistry using .toolRegistry
# include("llmfunction.jl") # include("llmfunction.jl")
+109 -62
View File
@@ -1,27 +1,55 @@
module toolRegistry module toolRegistry
export loadTools, registerTool, getTools, clearTools export ToolStore, loadTools, registerTool, getTools, clearTools, listTool
using Dates using Dates
using JSON, DataStructures using JSON, DataStructures
using ..type 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 Each agent gets its own `ToolStore` so tool registration is independent
const _tool_modules = Vector{Module}() `registerTool(store, tool)` only affects that agent's tool set.
# Auto-register the built-in listTools tool # Fields
function __init__() - `tools::OrderedDict{String, agentTool}` keyed by name for O(1) lookup + ordered iteration
registerTool(_listTool()) - `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 end
""" """
List tool definition lets the agent query available tools for collision detection List tool definition lets the agent query available tools for collision detection
when creating new tools via writeTool. 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( return agentTool(
name = "listTools", name = "listTools",
label = "List Tools", label = "List Tools",
@@ -32,11 +60,11 @@ function _listTool()::agentTool
"required" => Any[] "required" => Any[]
), ),
execute = (toolCallId, args, signal, onPartialResult) -> begin execute = (toolCallId, args, signal, onPartialResult) -> begin
tools = getTools() tools = getTools(store)
if isempty(tools) if isempty(tools)
result_text = "No tools registered." result_text = "No tools registered."
else 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") result_text = "Available tools:\n" * join(lines, "\n")
end end
return agentToolResult( return agentToolResult(
@@ -52,52 +80,41 @@ function _listTool()::agentTool
end 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 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 Each `.jl` file is loaded into its own **submodule** so that all functions
defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`,
and any helper functions) are namespaced and never collide with other tools. 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 # Arguments
- `store::ToolStore`: The tool store to register tools into
- `dir::String`: Directory path to scan for `.jl` tool files - `dir::String`: Directory path to scan for `.jl` tool files
# Returns # Returns
- `Vector{agentTool}`: All loaded tools - `OrderedDict{String, agentTool}`: All loaded tools keyed by name
# 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
# 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) if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir")) throw(ArgumentError("Tool directory does not exist: $dir"))
end end
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)
@@ -113,13 +130,13 @@ function loadTools(dir::String)::OrderedDict{String, agentTool}
# and constructing the module AST by hand is fragile. # and constructing the module AST by hand is fragile.
# Instead, we generate the full module source as a string, # Instead, we generate the full module source as a string,
# parse it, and eval the resulting expression. # parse it, and eval the resulting expression.
# Also import Dates, UUIDs, DataStructures, JSON — common dependencies # Each tool file declares its own dependencies via `using` statements
# that tool files use (and that the ..type module transitively uses). # 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) file_content = read(filepath, String)
module_code = """ module_code = """
module $(mod_name) module $(mod_name)
using ..type using ..type
using Dates, UUIDs, DataStructures, JSON
$(file_content) $(file_content)
end end
""" """
@@ -138,13 +155,8 @@ function loadTools(dir::String)::OrderedDict{String, agentTool}
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
)) ))
end end
# Keep module reference alive — closures in the agentTool (execute, store.tools[tool.name] = tool
# validateRequiredArgs, prepareArguments) may reference module-scoped println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))")
# 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 catch e
if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) if e isa UndefVarError || occursin("getTool", sprint(showerror, e))
throw(ArgumentError( throw(ArgumentError(
@@ -156,40 +168,75 @@ function loadTools(dir::String)::OrderedDict{String, agentTool}
end end
end end
return tools return store.tools
end end
""" """
Register a single agentTool into the global registry. Register a single agentTool into a specific ToolStore.
# Arguments # Arguments
- `store::ToolStore`: The tool store to register into
- `tool::agentTool`: The tool to register - `tool::agentTool`: The tool to register
# Returns # 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} function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool}
push!(_registry, tool) store.tools[tool.name] = tool
println("[toolRegistry] Registered tool: $(tool.name)") println("[$(store.name)] Registered tool: $(tool.name)")
return _registry return store.tools
end 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 # 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} function getTools(store::ToolStore)::OrderedDict{String, agentTool}
return deepcopy(_registry) return store.tools
end 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 function clearTools(store::ToolStore)::Nothing
empty!(_registry) empty!(store.tools)
println("[toolRegistry] Registry cleared") println("[$(store.name)] Registry cleared")
return nothing return nothing
end end
+1501 -404
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. 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. 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 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`. 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. up the new file. The new tool is immediately available.
# Example # Example
@@ -248,13 +250,13 @@ function getTool()::agentTool
tool_code = join(parts) 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) write(filepath, tool_code)
onPartialResult(Dict("status" => "Done")) onPartialResult(Dict("status" => "Done"))
return agentToolResult( 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}( Dict{Any,Any}(
"file" => filepath, "file" => filepath,
"name" => tool_name, "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 # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context
sessionId::Union{String, Nothing} # Optional session identifier sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function agentEventSink::Function # agent emits its status via this function
end _tool_store::Any # Reference to the ToolStore for runtime registration
end
""" """
Create a new yiemAgent instance with a background loop task. 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`) - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events - `agentEventSink::Function`: Callback to receive agent events
- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`)
# Returns # Returns
- A new `yiemAgent` instance with an active background task - A new `yiemAgent` instance with an active background task
# Examples # Examples
```julia ```julia
julia> tools = loadTools("src/tools") julia> store = ToolStore(name="agent1")
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=...) julia> tools = loadTools(store, "src/tools")
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) 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( function yiemAgent(
; systemPrompt::String="You are helpful assistant.", ; systemPrompt::String="You are helpful assistant.",
@@ -619,6 +622,7 @@ function yiemAgent(
maxRetryDelayMs::Union{Int64, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false, parallelToolExecute::Bool=false,
agentEventSink::Function, agentEventSink::Function,
tool_store::Union{Any, Nothing}=nothing,
) )
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16) inputChannel = Channel(16)
@@ -643,6 +647,7 @@ function yiemAgent(
maxRetryDelayMs, maxRetryDelayMs,
parallelToolExecute, parallelToolExecute,
agentEventSink, agentEventSink,
tool_store,
) )
# Spawn the background loop and attach it # Spawn the background loop and attach it
+58 -21
View File
@@ -6,12 +6,13 @@ using YiemAgent.type
# Path to the real tools directory # Path to the real tools directory
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools" begin @testset "loadTools with ToolStore" begin
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 1. loadTools throws on non-existent directory # # 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() # # 2. loadTools throws if a .jl file does not define getTool() #
@@ -20,12 +21,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
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(store, bad_dir)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ # # 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 !isempty(loaded)
@test length(loaded) == 3 @test length(loaded) == 3
@@ -98,15 +100,29 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test occursin("72°F", result_w2.content[1].text) @test occursin("72°F", result_w2.content[1].text)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools # # 7. getTools / registerTool / clearTools (per-store isolation) #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
registry_tools = getTools() store3 = ToolStore(name="test3")
@test !isempty(registry_tools) registry_tools = getTools(store3)
@test any(t -> t.name == "getTime", registry_tools) @test isempty(registry_tools)
@test any(t -> t.name == "getWeather", registry_tools)
clearTools() # listTool is not auto-registered anymore — each store starts empty
@test isempty(getTools()) # 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( test_tool = agentTool(
name = "manualTool", name = "manualTool",
@@ -119,18 +135,39 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = true parallelToolExecute = true
) )
registerTool(test_tool) registerTool(store3, test_tool)
reg = getTools() reg = getTools(store3)
@test any(t -> t.name == "manualTool", reg) @test haskey(reg, "manualTool")
@test count(t -> t.name == "manualTool", reg) == 1 @test length(reg) == 1
@test reg[1].parallelToolExecute == true @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() copy1 = getTools(store3)
copy2 = getTools() copy2 = getTools(store3)
@test copy1 !== copy2 @test copy1 === copy2 # same reference, not a deep copy
empty!(copy1) 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 end