Compare commits

...

11 Commits

Author SHA1 Message Date
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 1912 additions and 690 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
+243
View File
@@ -0,0 +1,243 @@
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
"""
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(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 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
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.
# Arguments
- `store::ToolStore`: The tool store to register tools into
- `dir::String`: Directory path to scan for `.jl` tool files
# Returns
- `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(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
return store.tools
end
"""
Register a single agentTool into a specific ToolStore.
# Arguments
- `store::ToolStore`: The tool store to register into
- `tool::agentTool`: The tool to register
# Returns
- `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(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool}
store.tools[tool.name] = tool
println("[$(store.name)] Registered tool: $(tool.name)")
return store.tools
end
"""
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
- `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(store::ToolStore)::OrderedDict{String, agentTool}
return store.tools
end
"""
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(store::ToolStore)::Nothing
empty!(store.tools)
println("[$(store.name)] Registry cleared")
return nothing
end
end # module
+1531 -434
View File
File diff suppressed because it is too large Load Diff
+22 -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,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 +73,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
-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
+65 -28
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,16 +21,17 @@ 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
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 +41,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 +57,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 +65,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 +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