From c78f4b023d96e9a9d46ab9fe3f761905d3d82297 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 14:55:09 +0700 Subject: [PATCH] update --- src/tools/README.md | 101 ++++++++++++++++--------- src/tools/registry.jl | 163 +++++++++++++++++++++++++++-------------- src/tools/writeTool.jl | 6 +- src/type.jl | 17 +++-- test/loadToolTest.jl | 74 +++++++++++++------ 5 files changed, 242 insertions(+), 119 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index a64b026..0f4cc43 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -8,7 +8,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework, 1. [Overview](#1-overview) 2. [Tool Definition — The `agentTool` Struct](#2-tool-definition--the-agenttool-struct) -3. [Tool Registration — The Global Registry](#3-tool-registration--the-global-registry) +3. [Tool Registration — Per-Agent Tool Stores](#3-tool-registration--per-agent-tool-stores) 4. [The Agent Loop — High-Level Flow](#4-the-agent-loop--high-level-flow) 5. [Message Processing Pipeline](#5-message-processing-pipeline) 6. [Tool Call Extraction from LLM Response](#6-tool-call-extraction-from-llm-response) @@ -110,66 +110,98 @@ The `terminate` flag is checked at the batch level. See [Section 9](#9-tool-call --- -## 3. Tool Registration — The Global Registry +## 3. Tool Registration — Per-Agent Tool Stores **Source:** `tools/registry.jl` -### How `loadTools()` Works +### How `ToolStore` Works + +The registry uses **per-agent isolated storage** via the `ToolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. ```julia -function loadTools(dir::String)::OrderedDict{String, agentTool} +struct ToolStore + tools::Vector{agentTool} # ordered tool list (for listTool iteration) + modules::Vector{Module} # keeps tool submodules alive to prevent GC + name::String # identifier for debugging/logs +end ``` -**Source:** `tools/registry.jl:95-160` +### How `loadTools(store, dir)` Works + +```julia +function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} +``` + +**Source:** `tools/registry.jl:115-180` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order 3. **Wraps** each file in a dynamically created submodule: - ```julia - # For "getWeather.jl" → module _tool_getWeather - module _tool_getWeather - using ..type - using Dates, UUIDs, DataStructures, JSON - # (file contents here) - end - ``` + ```julia + # For "getWeather.jl" → module _tool_getWeather + module _tool_getWeather + using ..type + using Dates, UUIDs, DataStructures, JSON + # (file contents here) + end + ``` 4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues 5. **Validates** the return value is an `agentTool` instance -6. **Stores** the module reference in `_tool_modules` to prevent GC of closures -7. **Registers** the tool in `_registry` and returns an `OrderedDict{String, agentTool}` +6. **Stores** the module reference in `store.modules` to prevent GC of closures +7. **Registers** the tool in `store.tools` and returns an `OrderedDict{String, agentTool}` ### Why Submodules? Each tool file is loaded into its own **namespaced submodule**. This means: - `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` - No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` -- The module reference is kept alive in `_tool_modules` so closures (in `execute`, `validateRequiredArgs`, `prepareArguments`) don't get garbage collected - -### Auto-Registration - -The `_listTool()` is auto-registered in `__init__()` (line 16-18), so `listTools` is always available without explicit loading. +- The module reference is kept alive in `store.modules` so closures (in `execute`, `validateRequiredArgs`, `prepareArguments`) don't get garbage collected ### Registration API ```julia -# Auto-load from directory (returns OrderedDict keyed by tool name) -tools = loadTools("src/tools") # OrderedDict{String, agentTool} +# Create per-agent stores +store1 = ToolStore(name="agent1") +store2 = ToolStore(name="agent2") -# Manual registration (adds to global _registry) -registerTool(my_tool) +# Load tools into specific stores +tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only +tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only + +# Manual registration (per-store) +registerTool(store1, my_tool) # Query (returns OrderedDict keyed by tool name, in registration order) -all_tools = getTools() # OrderedDict{String, agentTool} — O(1) lookup + deterministic order +all_tools = getTools(store1) # OrderedDict{String, agentTool} — O(1) lookup + deterministic order -# Clear -clearTools() # Empties _registry +# Clear (per-store) +clearTools(store1) # only clears store1 ``` -**Why `OrderedDict` for `getTools()`?** The internal `_registry` is a `Vector{agentTool}` for ordered iteration (used by `listTools`). `getTools()` builds an `OrderedDict` from `_registry` so callers get: +**Why `OrderedDict` for `getTools()`?** The internal `store.tools` is a `Vector{agentTool}` for ordered iteration (used by `listTool`). `getTools()` builds an `OrderedDict` from `store.tools` so callers get: - O(1) lookup by tool name -- Deterministic iteration order (registration order: `listTools` auto-registered first, then tools loaded alphabetically by filename) +- Deterministic iteration order (registration order) - Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) +### Per-Agent Isolation + +Each `ToolStore` is completely independent — tools registered in one store do not appear in another: + +```julia +storeA = ToolStore(name="A") +storeB = ToolStore(name="B") + +registerTool(storeA, getTime_tool) +registerTool(storeB, getWeather_tool) + +getTools(storeA) # only contains getTime +getTools(storeB) # only contains getWeather + +clearTools(storeA) # storeB is unaffected +``` + +This ensures that `yiemAgent` instances with different `tool_store` references operate with completely isolated tool sets. + --- ## 4. The Agent Loop — High-Level Flow @@ -185,6 +217,7 @@ yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() + - _tool_store (ToolStore) ← per-agent isolated tool registry ``` ### Loop States @@ -1083,11 +1116,11 @@ The framework supports tools that modify the tool system itself at runtime. 4. Appends `getTool()` returning an `agentTool` struct 5. Writes the combined string to `src/tools/.jl` -### `listTools` — Discover Available Tools +### `listTool` — Discover Available Tools -**Source:** `tools/registry.jl:24-52` +**Source:** `tools/registry.jl:54-82` -Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. +Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow @@ -1101,7 +1134,7 @@ Returns all registered tools. Primarily useful for **collision detection** befor - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - (optional) validateCode, prepareCode 3. writeTool generates src/tools/searchWine.jl -4. Agent restarts (or hot-reloads) → loadTools("src/tools") picks up the new file +4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file 5. Agent calls searchWine(query="cabernet") 6. Result: "Found 5 cabernet wines..." ``` @@ -1362,7 +1395,7 @@ module _tool_myTool end ``` -All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive in `_tool_modules` to prevent garbage collection of closures. +All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive in `store.modules` to prevent garbage collection of closures. --- diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 0884853..4315211 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -1,27 +1,57 @@ module toolRegistry -export loadTools, registerTool, getTools, clearTools +export ToolStore, loadTools, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures using ..type -# Global registry — populated at runtime by loadTools() or registerTool() -const _registry = Vector{agentTool}() +""" +Per-agent isolated tool storage. -# Module references — kept alive to prevent GC of tool code that closures depend on -const _tool_modules = Vector{Module}() +Each agent gets its own `ToolStore` so tool registration is independent — +`registerTool(store, tool)` only affects that agent's tool set. -# Auto-register the built-in listTools tool -function __init__() - registerTool(_listTool()) +# Fields +- `tools::Vector{agentTool}` — ordered tool list (for `listTool` iteration) +- `modules::Vector{Module}` — keeps tool submodules alive to prevent GC of closures +- `name::String` — identifier for debugging/logs +""" +struct ToolStore + tools::Vector{agentTool} + modules::Vector{Module} + 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(agentTool[], Module[], name) end """ List tool definition — lets the agent query available tools for collision detection when creating new tools via writeTool. + +# Arguments +- `store::ToolStore`: The tool store to list from + +Each `ToolStore` gets its own `listTool` instance bound to that store, +so each agent sees only its own tools. """ -function _listTool()::agentTool +function listTool(store::ToolStore)::agentTool return agentTool( name = "listTools", label = "List Tools", @@ -32,7 +62,7 @@ function _listTool()::agentTool "required" => Any[] ), execute = (toolCallId, args, signal, onPartialResult) -> begin - tools = getTools() + tools = getTools(store) if isempty(tools) result_text = "No tools registered." else @@ -52,47 +82,37 @@ function _listTool()::agentTool end """ -Load all tool modules from a directory. +Load all tool modules from a directory into a specific ToolStore. Scans `dir` for `.jl` files. Each file must define a function named -`getTool()::agentTool`. Files are sorted alphabetically so tool +`getTool()::agentTool`. Files are sorted alphabetically so tool registration order is deterministic. Each `.jl` file is loaded into its own **submodule** so that all functions defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, and any helper functions) are namespaced and never collide with other tools. -# Tool file format -Each `.jl` file defines one function `getTool()` that returns an `agentTool`. -Inside the file you can freely define as many helper functions as you need — -they will all be scoped under the tool's submodule. - -```julia -# src/tools/getWeather.jl - -# These are namespaced — no collision with getTime.validateRequiredArgs, etc. -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - ... -end - -function getTool()::agentTool - return agentTool( - name = "getWeather", - ... - ) -end -``` - # Arguments +- `store::ToolStore`: The tool store to register tools into - `dir::String`: Directory path to scan for `.jl` tool files # Returns -- `Vector{agentTool}`: All loaded tools +- `OrderedDict{String, agentTool}`: All loaded tools keyed by name # Errors - Throws `ArgumentError` if a tool file does not define a `getTool` function + +# Examples +```julia +julia> store = ToolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +OrderedDict{String, agentTool} with 3 entries: + "getWeather" => agentTool(...) + "getTime" => agentTool(...) + "listTools" => agentTool(...) +``` """ -function loadTools(dir::String)::OrderedDict{String, agentTool} +function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end @@ -141,10 +161,10 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} # Keep module reference alive — closures in the agentTool (execute, # validateRequiredArgs, prepareArguments) may reference module-scoped # functions. Without this, GC could collect the module. - push!(_tool_modules, mod) - push!(_registry, tool) + push!(store.modules, mod) + push!(store.tools, tool) tools[tool.name] = tool - println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") + println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))") catch e if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) throw(ArgumentError( @@ -160,42 +180,75 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} end """ -Register a single agentTool into the global registry. +Register a single agentTool into a specific ToolStore. # Arguments +- `store::ToolStore`: The tool store to register into - `tool::agentTool`: The tool to register # Returns -- `Vector{agentTool}`: Updated registry +- `Vector{agentTool}`: Updated tool list for this store + +# Examples +```julia +julia> store = ToolStore(name="agent1") +julia> registerTool(store, my_tool) +[toolRegistry:agent1] Registered tool: my_tool +``` """ -function registerTool(tool::agentTool)::Vector{agentTool} - push!(_registry, tool) - println("[toolRegistry] Registered tool: $(tool.name)") - return _registry +function registerTool(store::ToolStore, tool::agentTool)::Vector{agentTool} + push!(store.tools, tool) + println("[$(store.name)] Registered tool: $(tool.name)") + return store.tools end """ -Get all registered tools as an `OrderedDict{String, agentTool}` keyed by tool name. +Get all registered tools from a specific ToolStore as an +`OrderedDict{String, agentTool}` keyed by tool name. -The internal `_registry` is a `Vector` for ordered iteration (used by `listTools`). -This function builds an `OrderedDict` from `_registry` so callers get: +The internal vector is for ordered iteration (used by `listTool`). +This function builds an `OrderedDict` so callers get: - O(1) lookup by name -- Deterministic iteration order (registration order: alphabetical by filename) +- Deterministic iteration order (registration order) - Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) +# Arguments +- `store::ToolStore`: The tool store to query + # Returns -- `OrderedDict{String, agentTool}`: Copy of the registry keyed by tool name, in registration order +- `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()::OrderedDict{String, agentTool} - return OrderedDict{String, agentTool}(t.name => t for t in _registry) +function getTools(store::ToolStore)::OrderedDict{String, agentTool} + return OrderedDict{String, agentTool}(t.name => t for t in store.tools) end """ -Clear all registered tools from the global registry. +Clear all registered tools from a specific ToolStore. + +# Arguments +- `store::ToolStore`: The tool store to clear + +# Returns +- `nothing` + +# Examples +```julia +julia> clearTools(store) +[toolRegistry:agent1] Registry cleared +``` """ -function clearTools()::Nothing - empty!(_registry) - println("[toolRegistry] Registry cleared") +function clearTools(store::ToolStore)::Nothing + empty!(store.tools) + println("[$(store.name)] Registry cleared") return nothing end diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index 73dc6da..c682e1d 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -7,7 +7,7 @@ The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so `loadTools("src/tools")` picks +After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks up the new file. The new tool is immediately available. # Example @@ -250,13 +250,13 @@ function getTool()::agentTool tool_code = join(parts) - # Write the file — tool is loaded on next agent restart via loadTools() + # Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools") write(filepath, tool_code) onPartialResult(Dict("status" => "Done")) return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")], Dict{Any,Any}( "file" => filepath, "name" => tool_name, diff --git a/src/type.jl b/src/type.jl index ee373cf..37b614f 100644 --- a/src/type.jl +++ b/src/type.jl @@ -567,9 +567,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function -end + parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function + _tool_store::Any # Reference to the ToolStore for runtime registration + end """ Create a new yiemAgent instance with a background loop task. @@ -593,15 +594,17 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `agentEventSink::Function`: Callback to receive agent events +- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task # Examples ```julia -julia> tools = loadTools("src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=...) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) +julia> store = ToolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) """ function yiemAgent( ; systemPrompt::String="You are helpful assistant.", @@ -619,6 +622,7 @@ function yiemAgent( maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, agentEventSink::Function, + tool_store::Union{Any, Nothing}=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -643,6 +647,7 @@ function yiemAgent( maxRetryDelayMs, parallelToolExecute, agentEventSink, + tool_store, ) # Spawn the background loop and attach it diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index f0f10df..1c16faf 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -6,12 +6,13 @@ using YiemAgent.type # Path to the real tools directory TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") -@testset "loadTools" begin +@testset "loadTools with ToolStore" begin # ------------------------------------------------------------------ # # 1. loadTools throws on non-existent directory # # ------------------------------------------------------------------ # - @test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist") + store = ToolStore(name="test1") + @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") # ------------------------------------------------------------------ # # 2. loadTools throws if a .jl file does not define getTool() # @@ -20,12 +21,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # bad_dir = mktempdir() write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") - @test_throws ArgumentError loadTools(bad_dir) + @test_throws ArgumentError loadTools(store, bad_dir) # ------------------------------------------------------------------ # # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - loaded = loadTools(TOOLS_DIR) + store2 = ToolStore(name="test2") + loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) @test length(loaded) == 3 @@ -98,20 +100,29 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test occursin("72°F", result_w2.content[1].text) # ------------------------------------------------------------------ # - # 7. getTools / registerTool / clearTools # + # 7. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # - registry_tools = getTools() - @test !isempty(registry_tools) - @test "getTime" in keys(registry_tools) - @test "getWeather" in keys(registry_tools) - # listTools is auto-registered via __init__() → first key, then tools loaded alphabetically - @test collect(keys(registry_tools))[1] == "listTools" - @test collect(keys(registry_tools))[2] == "getTime" - @test collect(keys(registry_tools))[3] == "getWeather" - @test collect(keys(registry_tools))[4] == "writeTool" + store3 = ToolStore(name="test3") + registry_tools = getTools(store3) + @test isempty(registry_tools) - clearTools() - @test isempty(getTools()) + # listTool is not auto-registered anymore — each store starts empty + # Register tools manually + registerTool(store3, loaded["getTime"]) + registerTool(store3, loaded["getWeather"]) + registerTool(store3, loaded["writeTool"]) + + reg = getTools(store3) + @test !isempty(reg) + @test "getTime" in keys(reg) + @test "getWeather" in keys(reg) + @test "writeTool" in keys(reg) + @test collect(keys(reg))[1] == "getTime" + @test collect(keys(reg))[2] == "getWeather" + @test collect(keys(reg))[3] == "writeTool" + + clearTools(store3) + @test isempty(getTools(store3)) test_tool = agentTool( name = "manualTool", @@ -124,8 +135,8 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") validateRequiredArgs = nothing, parallelToolExecute = true ) - registerTool(test_tool) - reg = getTools() + registerTool(store3, test_tool) + reg = getTools(store3) @test haskey(reg, "manualTool") @test length(reg) == 1 @test reg["manualTool"].parallelToolExecute == true @@ -133,9 +144,30 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 8. getTools returns deep copy (mutations don't affect registry) # # ------------------------------------------------------------------ # - copy1 = getTools() - copy2 = getTools() + copy1 = getTools(store3) + copy2 = getTools(store3) @test copy1 !== copy2 empty!(copy1) - @test !isempty(getTools()) + @test !isempty(getTools(store3)) + + # ------------------------------------------------------------------ # + # 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