diff --git a/docs/loadtools.md b/docs/loadtools.md new file mode 100644 index 0000000..c222248 --- /dev/null +++ b/docs/loadtools.md @@ -0,0 +1,92 @@ +# Dynamic Tool Loading + +Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory without hardcoding filenames in the main module. + +## How It Works + +1. `src/tools/registry.jl` defines a `load_tools(dir::String)` function that scans a directory for `.jl` files +2. Each tool file must define a single function: `get_tool() :: agentTool` +3. `load_tools()` sorts files alphabetically, includes each one, calls `get_tool()`, and registers the result +4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent` + +## Directory Structure + +``` +src/ +├── tools/ +│ ├── registry.jl # Tool loader (do not edit) +│ ├── get_weather.jl # Your tool +│ └── query_db.jl # Another tool +├── type.jl +├── utils.jl +├── agentCore.jl +├── api.jl +└── YiemAgent.jl +``` + +## Creating a Tool + +Each `.jl` file in `src/tools/` must define `get_tool()` returning an `agentTool`: + +```julia +# src/tools/get_weather.jl +function get_tool() :: agentTool + return agentTool( + name = "get_weather", + 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("Weather in $(city): Sunny, 22C")], + Dict{Any,Any}(), nothing, false + ) + end, + prepareArguments = nothing, + parallelToolExecute = false + ) +end +``` + +No `module` wrapper needed — the registry includes each file in the current module scope so all types (`agentTool`, `textContent`, `agentToolResult`, etc.) resolve correctly. + +## Loading Tools + +```julia +using .YiemAgent +using .YiemAgent: toolRegistry + +# Load all tool files from src/tools/ +tools = load_tools(joinpath(@__DIR__, "src", "tools")) + +# Create agent with loaded tools +agent = yiemAgent( + systemPrompt = "You are a helpful assistant.", + model = my_model, + tools = tools, + llmCall = my_llm_call, + agentEventSink = my_event_sink +) +``` + +## Available Functions + +| Function | Description | +|----------|-------------| +| `load_tools(dir::String)` | Scan directory and load all `.jl` tool files | +| `register_tool(tool::agentTool)` | Register a single tool into the global registry | +| `get_tools()` | Get deep copy of all registered tools | +| `list_tools()` | List all registered tools as `(name, label)` pairs | +| `clear_tools()` | Clear the global registry | + +## File Loading Order + +Files are sorted alphabetically before loading, so `01_database.jl` loads before `02_weather.jl`. This ensures deterministic registration order. diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 764574c..765807e 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -13,6 +13,9 @@ module YiemAgent include("utils.jl") using .utils + include("tools/registry.jl") + using .toolRegistry + # include("llmfunction.jl") # using .llmfunction diff --git a/src/tools/get_weather.jl b/src/tools/get_weather.jl new file mode 100644 index 0000000..808586a --- /dev/null +++ b/src/tools/get_weather.jl @@ -0,0 +1,59 @@ +""" +Execute the get_weather tool. + +# Arguments +- `toolCallId::String`: Unique identifier for this tool call +- `args::Dict{String,Any}`: Parsed arguments from the LLM +- `signal::Union{Nothing,abortSignal}`: Optional abort signal +- `onPartialResult::Function`: Callback for streaming partial results + +# Returns +- `agentToolResult`: Result content with weather data +""" +function execute_tool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) :: agentToolResult + city = get(args, "city", "") + units = get(args, "units", "celsius") + + # Validate required arguments + if isempty(city) + return agentToolResult( + [textContent("Error: 'city' argument is required.")], + Dict{Any,Any}(), nothing, false + ) + end + + # Simulate weather fetch — replace with actual API call + # You can call onPartialResult() here for streaming progress updates: + # onPartialResult(Dict("status" => "Fetching weather data...")) + # onPartialResult(Dict("status" => "Processing...")) + + 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 get_weather agentTool. +""" +function get_tool() :: agentTool + return agentTool( + name = "get_weather", + 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, e.g., 'San Francisco, CA' or 'Tokyo, Japan'"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius", "description" => "Temperature scale") + ), + "required" => ["city"] + ), + execute = execute_tool, # reference the function defined above + prepareArguments = nothing, + parallelToolExecute = false + ) +end diff --git a/src/tools/registry.jl b/src/tools/registry.jl new file mode 100644 index 0000000..de121fd --- /dev/null +++ b/src/tools/registry.jl @@ -0,0 +1,142 @@ +module toolRegistry + +export load_tools, register_tool, get_tools, list_tools, clear_tools + +using ..type + +# Global registry — populated at runtime by load_tools() or register_tool() +const _registry = Vector{agentTool}() + +""" +Load all tool modules from a directory. + +Scans `dir` for `.jl` files. Each file must define a function named +`get_tool() :: agentTool`. Files are sorted alphabetically so tool +registration order is deterministic. + +# Tool file format +Each `.jl` file defines one function `get_tool()` that returns an `agentTool`: + +```julia +# src/tools/get_weather.jl +function get_tool() :: agentTool + return agentTool( + name = "get_weather", + 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 $(city)")], + 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 `get_tool` function +""" +function load_tools(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"), 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 get_tool was defined (include() places it in current module scope) + if !isdefined(:get_tool) + throw(ArgumentError( + "Tool file $(filepath) does not define a `get_tool()` function. " * + "Each tool file must define: function get_tool() :: agentTool ... end" + )) + end + + # Call get_tool() — it runs in current scope where types are visible + tool = get_tool() + if !(tool isa agentTool) + throw(ArgumentError( + "get_tool() 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 register_tool(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 get_tools()::Vector{agentTool} + return deepcopy(_registry) +end + +""" +List all registered tool names and labels. + +# Returns +- `Vector{Tuple{String,String}}`: Pairs of (name, label) +""" +function list_tools()::Vector{Tuple{String,String}} + return [(t.name, t.label) for t in _registry] +end + +""" +Clear all registered tools from the global registry. +""" +function clear_tools()::Nothing + empty!(_registry) + println("[toolRegistry] Registry cleared") + return nothing +end + +end # module diff --git a/src/type.jl b/src/type.jl index 46f95bc..66d4cda 100644 --- a/src/type.jl +++ b/src/type.jl @@ -197,23 +197,67 @@ end """ A tool available to the agent. +Maps MCP server tool definitions to an executable Julia tool. + # Arguments -- `name::String`: Tool identifier -- `label::String`: Human-readable tool name -- `description::String`: What the tool does -- `parameters::TParameters`: Tool parameters schema (JSON schema) -- `execute::Function`: Tool execution function +- `name::String`: Tool identifier (from MCP `name`) +- `label::String`: Human-readable tool name (from MCP `title`) +- `description::String`: What the tool does (from MCP `description`) +- `inputSchema::Any`: Tool parameters schema (from MCP `inputSchema`, JSON Schema format) +- `execute::Function`: Tool execution function, signature: + `execute(toolCallId::String, args::Dict, signal::Union{Nothing,AbortSignal}, onPartialResult::Function)` - `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback - `parallelToolExecute::Bool`: Override: run tool calls sequentially or in parallel # Returns - A new `agentTool` instance + +# MCP Tool Example +``` +{ + "name": "get_weather", + "title": "Weather Lookup", + "description": "Fetch current weather and forecast for a given city.", + "inputSchema": { + "type": "object", + "properties": { + "city": { "type": "string", "description": "City and state/country" }, + "units": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius" } + }, + "required": ["city"] + } +} +``` + +# Example +```julia +tool = agentTool( + name="get_weather", + label="Weather Lookup", + description="Fetch current weather and forecast for a given city.", + inputSchema=Dict( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City name"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"]) + ), + "required" => ["city"] + ), + execute=(toolCallId, args, signal, onPartialResult) -> begin + city = args["city"] + return agentToolResult( + [textContent("Sunny, 22C in $(city)")], + Dict{Any,Any}(), nothing, false + ) + end +) +``` """ -struct agentTool{TParameters, TDetails} # A tool available to the agent +struct agentTool # A tool available to the agent name::String # Tool identifier label::String # Human-readable tool name description::String # What the tool does - parameters::TParameters # Tool parameters schema (JSON schema) + inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) execute::Function # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel