Compare commits

...

13 Commits

Author SHA1 Message Date
ton 5a27630ccf update 2026-08-11 12:15:05 +07:00
ton ed91260468 update 2026-08-10 20:37:28 +07:00
ton c13aeb3a74 Merge pull request 'V0.8.0 verify tool use' (#43) from v0.8.0-verify_tool_use into v0.8.0
Reviewed-on: #43
2026-08-10 13:10:57 +00:00
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
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
10 changed files with 1987 additions and 693 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")
+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
+271
View File
@@ -0,0 +1,271 @@
module toolRegistry
export toolStore, loadTools, registerTool, getTools, clearTools, listTool
using Dates
using JSON, DataStructures
using ..type
"""
Per-agent isolated tool storage.
Each agent gets its own `toolStore` so tool registration is independent —
`registerTool(store, tool)` only affects that agent's tool set.
# 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
"""
toolStore(; name="default") -> toolStore
Create a new empty tool store.
# Keyword Arguments
- `name::String`: Display name for logging (default: `"default"`)
# Example
```julia
julia> store = toolStore(name="agent1")
toolStore(OrderedDict{String, agentTool}(), "agent1")
```
"""
function toolStore(; name::String="default")::toolStore
toolStore(OrderedDict{String, agentTool}(), name)
end
"""
listTool(store::toolStore) -> agentTool
Return an `agentTool` definition for listing registered tools.
Each call produces a **new** tool object that captures (closes over)
`store`. `loadTools` auto-registers one so the LLM can discover tools
at runtime.
# Arguments
- `store`: The tool store whose tools will be listed when the tool runs
# Example
```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools") # auto-registers listTools
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Registered tool: listTools
julia> tools = getTools(store)
OrderedDict{String, agentTool} with 4 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"writeTool" => agentTool(...)
"listTools" => agentTool(...)
```
"""
function listTool(store::toolStore)::agentTool
return agentTool(
name = "listTools",
label = "List Tools",
description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
tools = getTools(store)
if isempty(tools)
result_text = "No tools registered."
else
lines = String["- $(t.name): $(t.label)$(t.description)" for (k, t) in tools]
result_text = "Available tools:\n" * join(lines, "\n")
end
return agentToolResult(
[textContent(result_text)],
Dict{Any,Any}("count" => length(tools)),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
"""
Load `.jl` tool files from `dir` into `store`, then auto-register
`listTool` so the LLM can discover available tools at runtime.
Each `.jl` file must define `function getTool()::agentTool ... end`.
Files are sorted alphabetically for deterministic registration order.
Each file is loaded into its own Julia submodule to avoid name collisions.
# Arguments
- `store`: Tool store to populate
- `dir`: Directory containing `.jl` tool files
# Returns
- The same `store.tools` dict (modified in place)
# Errors
- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()`
# Example
```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools")
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Loaded tool: getTime (Time Lookup)
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 3 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"listTools" => agentTool(...)
```
"""
function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir"))
end
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
sort!(jl_files)
for filename in jl_files
filepath = joinpath(dir, filename)
# 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" => ""))
# 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.
# 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
$(file_content)
end
"""
mod = eval(Meta.parse(module_code))
# 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
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(
"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
registerTool(store, listTool(store))
return store.tools
end
"""
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
Add `tool` to `store`, overwriting any existing tool with the same name.
# Arguments
- `store`: Tool store to modify
- `tool`: The `agentTool` to register
# Returns
- The same `store.tools` dict (modified in place)
# Example
```julia
julia> store = toolStore(name="agent1");
julia> registerTool(store, listTool(store))
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 1 entry:
"listTools" => agentTool(...)
```
"""
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
"""
Return the tools registered in `store`.
The returned dict is the **same object** stored inside `store` — mutations
to it (e.g. via `registerTool`) are visible through subsequent calls.
# Arguments
- `store`: Tool store to query
# Returns
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
# Example
```julia
julia> tools = getTools(store)
OrderedDict{String, agentTool} with 2 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
```
"""
function getTools(store::toolStore)::OrderedDict{String, agentTool}
return store.tools
end
"""
Remove all tools from `store`.
# Arguments
- `store`: Tool store to clear
# Returns
- `nothing`
# Example
```julia
julia> clearTools(store)
[toolRegistry:agent1] Registry cleared
nothing
julia> getTools(store)
OrderedDict{String, agentTool} with 0 entries
```
"""
function clearTools(store::toolStore)::Nothing
empty!(store.tools)
println("[$(store.name)] Registry cleared")
return nothing
end
end # module
+1534 -434
View File
File diff suppressed because it is too large Load Diff
+23 -13
View File
@@ -1,3 +1,5 @@
using Dates
""" """
Validate required arguments for the getTime tool. Validate required arguments for the getTime tool.
@@ -36,6 +38,26 @@ 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 +74,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
+18 -10
View File
@@ -1,3 +1,20 @@
"""
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 +31,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
-174
View File
@@ -1,174 +0,0 @@
module toolRegistry
export loadTools, registerTool, getTools, clearTools
using Dates
using JSON
using ..type
# Global registry — populated at runtime by loadTools() or registerTool()
const _registry = Vector{agentTool}()
# Auto-register the built-in listTools tool
function __init__()
registerTool(_listTool())
end
"""
List tool definition — lets the agent query available tools for collision detection
when creating new tools via writeTool.
"""
function _listTool()::agentTool
return agentTool(
name = "listTools",
label = "List Tools",
description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
tools = getTools()
if isempty(tools)
result_text = "No tools registered."
else
lines = String["- $(t.name): $(t.label)$(t.description)" for t in tools]
result_text = "Available tools:\n" * join(lines, "\n")
end
return agentToolResult(
[textContent(result_text)],
Dict{Any,Any}("count" => length(tools)),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
"""
Load all tool modules from a directory.
Scans `dir` for `.jl` files. Each file must define a function named
`getTool()::agentTool`. Files are sorted alphabetically so tool
registration order is deterministic.
# Tool file format
Each `.jl` file defines one function `getTool()` that returns an `agentTool`:
```julia
# src/tools/getWeather.jl
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
```
# Arguments
- `dir::String`: Directory path to scan for `.jl` tool files
# Returns
- `Vector{agentTool}`: All loaded tools
# Errors
- Throws `ArgumentError` if a tool file does not define a `getTool` function
"""
function loadTools(dir::String)::Vector{agentTool}
if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir"))
end
tools = agentTool[]
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
sort!(jl_files)
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)
# 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"
))
end
# 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))"
))
end
push!(_registry, tool)
push!(tools, tool)
println("[toolRegistry] Loaded tool: $(tool.name)$(tool.label)")
end
return tools
end
"""
Register a single agentTool into the global registry.
# Arguments
- `tool::agentTool`: The tool to register
# Returns
- `Vector{agentTool}`: Updated registry
"""
function registerTool(tool::agentTool)::Vector{agentTool}
push!(_registry, tool)
println("[toolRegistry] Registered tool: $(tool.name)")
return _registry
end
"""
Get all registered tools.
# Returns
- `Vector{agentTool}`: Copy of the registry
"""
function getTools()::Vector{agentTool}
return deepcopy(_registry)
end
"""
Clear all registered tools from the global registry.
"""
function clearTools()::Nothing
empty!(_registry)
println("[toolRegistry] Registry cleared")
return nothing
end
end # module
+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,
+24 -19
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
@@ -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.
@@ -580,7 +581,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)
@@ -593,20 +594,22 @@ 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> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model) julia> store = toolStore(name="agent1")
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) 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( 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,
@@ -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
+107 -31
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,34 +21,36 @@ 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) == 4 # 3 files + auto-registered listTools
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
@test "listTools" in names
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically by filename # # 4. loadTools returns tools sorted alphabetically by filename #
# (getTime.jl < getWeather.jl < writeTool.jl) # # (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end #
# 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"
@test collect(keys(loaded))[4] == "listTools"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 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 +58,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 +66,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
@@ -98,15 +101,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 +136,77 @@ 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
@testset "listTool" begin
store = toolStore(name="test_list")
loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools
# loadTools auto-registers listTool
@test "listTools" in keys(loaded)
# listTool returns an agentTool, not a string or array
list_t = listTool(store)
@test list_t isa agentTool
@test list_t.name == "listTools"
@test list_t.label == "List Tools"
@test isempty(list_t.inputSchema["required"])
# Verify all tools appear (3 loaded + listTools = 4)
result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x)
@test result isa agentToolResult
@test result.content[1] isa textContent
@test occursin("listTools", result.content[1].text)
@test occursin("getWeather", result.content[1].text)
@test occursin("getTime", result.content[1].text)
@test occursin("writeTool", result.content[1].text)
@test result.details["count"] == 4
# Each listTool call creates an independent closure
storeB = toolStore(name="test_listB")
registerTool(storeB, loaded["getWeather"])
list_tB = listTool(storeB)
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x)
resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x)
@test occursin("getWeather", resultA.content[1].text)
@test occursin("getWeather", resultB.content[1].text)
@test occursin("getTime", resultA.content[1].text)
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
end end