diff --git a/src/tools/README.md b/src/tools/README.md index 0a3a8a8..9b7df7a 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -393,9 +393,140 @@ println(response.content) # => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" ``` +## Self-Modifying Tools + +The framework includes tools that allow the agent to create new tools at runtime. + +### `writeTool` — Create New Tool Files + +The `writeTool` tool generates a new Julia tool file at `src/tools/.jl`. The agent writes the tool, then the agent (or system) restarts so `loadTools("src/tools")` picks it up. + +**Workflow:** + +1. Agent identifies a task that no existing tool can handle +2. Agent calls `writeTool` with a tool specification: + +```julia +# Agent sends this to writeTool: +Dict( + "name" => "searchWine", + "label" => "Wine Search", + "description" => "Search a wine database by name, region, or variety", + "inputSchema" => Dict( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "maxResults" => Dict("type" => "integer", "default" => 10) + ), + "required" => ["query"] + ), + "executeCode" => """ + query = args["query"] + max_results = get(args, "maxResults", 10) + # Perform search logic here + result = "Found 3 wines matching: $query" + return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) + """, + "parallel" => false +) +``` + +3. Agent calls `listTools` to check for name collisions (built-in, auto-registered) +4. Agent calls `writeTool` with a unique name +5. Restart agent — `loadTools("src/tools")` picks up the new file +6. Agent's next LLM turn discovers and calls the new tool + +**Generated file format:** + +```julia +# Auto-generated tool: searchWine +# Generated by writeTool at 2026-08-08T14:00:00 + +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + query = args["query"] + max_results = get(args, "maxResults", 10) + result = "Found 3 wines matching: $query" + return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) +end + +function getTool()::agentTool + return agentTool( + name = "searchWine", + label = "Wine Search", + description = "Search a wine database by name, region, or variety", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "maxResults" => Dict("type" => "integer", "default" => 10) + ), + "required" => ["query"] + ), + execute = executeTool, + validateRequiredArgs = nothing, + prepareArguments = nothing, + parallelToolExecute = false + ) +end +``` + +**Optional hooks:** + +| Field | Description | +|---|---| +| `validateCode` | Custom validation Julia code (runs before execute). Return `nothing` to pass, or an error `String` to fail. | +| `prepareCode` | Argument preparation code (runs before validation). Return modified args dict. | + +### `listTools` — Discover Available Tools + +Returns a list of all registered tools with names, labels, and descriptions. + +```julia +# Result from listTools: +# Available tools: +# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city. +# - getTime: Time Lookup — Get current local time for a timezone or city. +# - writeTool: Create Tool — Generate new tool files... +# - listTools: List Tools — List all available tools with their names and labels... +``` + +### Complete Self-Tooling Example + +``` +User: "I need to search for wines. Do you have a tool for that?" + +# ─── LOOP: Agent realizes no wine search tool exists ───────────────── + +[Tool Call] listTools() +# Result: lists all available tools — no collision with existing tools + +[Tool Call] writeTool(name="searchWine", label="Wine Search", + description="Search a wine database by name, region, or variety", + inputSchema={...}, + executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") + +# writeTool generates searchWine.jl + +# ─── SYSTEM RESTARTS ───────────────────────────────────────────────── + +# Agent restarts — loadTools("src/tools") picks up searchWine.jl +# searchWine is now available — agent uses it directly + +# ─── Agent calls the new tool ───────────────────────────────────────── + +[Tool Call] searchWine(query="cabernet", maxResults=5) +# Result: "Found 5 cabernet wines..." + +# ─── Final response ────────────────────────────────────────────────── + +"The search found 5 cabernet wines: ..." +``` + ## Available Tools | Tool | Description | Validation | |---|---|---| | `getWeather` | Fetch weather for a city | Default (JSON Schema required) | | `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | +| `writeTool` | Create a new Julia tool module at runtime | Built-in (name + schema validation) | +| `listTools` | List all available tools with descriptions | None (no arguments) | diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 5ce045f..65b1085 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -1,12 +1,51 @@ module toolRegistry -export loadTools, registerTool, getTools, listTools, clearTools +export loadTools, registerTool, getTools, clearTools 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. @@ -120,16 +159,6 @@ function getTools()::Vector{agentTool} return deepcopy(_registry) end -""" -List all registered tool names and labels. - -# Returns -- `Vector{Tuple{String,String}}`: Pairs of (name, label) -""" -function listTools()::Vector{Tuple{String,String}} - return [(t.name, t.label) for t in _registry] -end - """ Clear all registered tools from the global registry. """ diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl new file mode 100644 index 0000000..a318232 --- /dev/null +++ b/src/tools/writeTool.jl @@ -0,0 +1,269 @@ +""" +Tool that generates new Julia tool module files. + +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 the new tool is loaded by +`loadTools("src/tools")`. Then call `listTools` to verify the new tool +is available. + +# Example + +1. Agent calls writeTool with a spec for a "searchWine" tool +2. writeTool generates src/tools/searchWine.jl +3. Restart agent — loadTools() picks up the new file +4. Agent calls searchWine with args + +# Important Notes + +- The `executeCode` string is embedded literally into the generated tool. + Use `args["param_name"]` to access input parameters. +- The code string should be the function body (NOT wrapped in a function). + Lines will be indented with 4 spaces inside the execute function. +- Tool names must be valid Julia identifiers (lowercase letters, digits, underscores, + no leading digits or special characters). +""" + +""" +Validate that a tool name is a valid Julia identifier. +""" +function validateToolName(name::String)::Union{Nothing,String} + if !occursin(r"^[a-zA-Z_][a-zA-Z0-9_!]*$", name) + return "Invalid tool name: '$name'. Tool names must be valid Julia identifiers (letters, digits, underscores, starting with a letter or underscore)." + end + return nothing +end + +""" +Execute the writeTool. + +Generates a new .jl tool file and registers it with the tool registry. +""" +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + tool_name = get(args, "name", "")::String + tool_label = get(args, "label", tool_name)::String + tool_description = get(args, "description", "")::String + tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} + execute_code = get(args, "executeCode", "")::String + validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} + prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} + parallel = get(args, "parallel", false)::Bool + + # Validate tool name + name_err = validateToolName(tool_name) + if name_err !== nothing + return agentToolResult( + [textContent(name_err)], + Dict{Any,Any}(), nothing, false + ) + end + + # Validate required fields + if isempty(tool_name) + return agentToolResult( + [textContent("Missing required field: 'name'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(tool_description) + return agentToolResult( + [textContent("Missing required field: 'description'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(execute_code) + return agentToolResult( + [textContent("Missing required field: 'executeCode'")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Generating tool: $tool_name")) + + # Build the tool file path + script_dir = dirname(@__FILE__) + tools_dir = dirname(script_dir) + filepath = joinpath(tools_dir, "$(tool_name).jl") + + # Check for naming conflicts + if isfile(filepath) + return agentToolResult( + [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) + + # Convert schema Dict to a Julia Dict literal string + schema_literal = dict_to_julia_literal(tool_schema) + + # Build optional validation function + validate_section = if validate_code !== nothing && !isempty(validate_code) + indented = indent_code(validate_code, 4) + "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" + else + "" + end + + # Build optional prepare function + prepare_section = if prepare_code !== nothing && !isempty(prepare_code) + indented = indent_code(prepare_code, 4) + "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" + else + "" + end + + # Indent user's execute code for embedding inside execute function body + indented_exec = indent_code(execute_code, 4) + + # Escape description for Julia string literal + escaped_desc = replace(tool_description, "\\" => "\\\\") + escaped_desc = replace(escaped_desc, "\"" => "\\\"") + + # Build the complete tool file content + parts = String[] + push!(parts, "# Auto-generated tool: $tool_name\n") + push!(parts, "# Generated by writeTool at $(now())\n\n") + if !isempty(validate_section) + push!(parts, validate_section) + push!(parts, "\n") + end + if !isempty(prepare_section) + push!(parts, prepare_section) + push!(parts, "\n") + end + push!(parts, "\n") + push!(parts, "# Execute function\n") + push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") + push!(parts, "$indented_exec\n") + push!(parts, "end\n\n") + push!(parts, "# Tool definition\n") + push!(parts, "function getTool()::agentTool\n") + push!(parts, " return agentTool(\n") + push!(parts, " name = \"$(tool_name)\",\n") + push!(parts, " label = \"$(tool_label)\",\n") + push!(parts, " description = \"$(escaped_desc)\",\n") + push!(parts, " inputSchema = $schema_literal,\n") + push!(parts, " execute = executeTool,\n") + if validate_code !== nothing && !isempty(validate_code) + push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") + else + push!(parts, " validateRequiredArgs = nothing,\n") + end + if prepare_code !== nothing && !isempty(prepare_code) + push!(parts, " prepareArguments = prepareArguments,\n") + else + push!(parts, " prepareArguments = nothing,\n") + end + push!(parts, " parallelToolExecute = $parallel\n") + push!(parts, " )\n") + push!(parts, "end\n") + + tool_code = join(parts) + + # Write the file — tool is loaded on next agent restart via loadTools() + 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.")], + Dict{Any,Any}( + "file" => filepath, + "name" => tool_name, + "label" => tool_label, + "description" => tool_description, + ), + nothing, false + ) +end + +""" +Indent a multi-line code string by the specified number of spaces. +""" +function indent_code(code::String, n::Int)::String + prefix = " "^n + lines = split(code, '\n') + result_lines = String[prefix * line for line in lines] + return join(result_lines, "\n") +end + +""" +Convert a Julia Dict to a valid Julia Dict{String,Any}(...) literal string. +""" +function dict_to_julia_literal(d)::String + if d isa Dict + items = String[] + for (k, v) in d + key_str = json_string(k) + val_str = value_to_julia(v) + push!(items, "$key_str => $val_str") + end + return "Dict{String,Any}(" * join(items, ", ") * ")" + else + return value_to_julia(d) + end +end + +function value_to_julia(v)::String + if v isa Dict + return dict_to_julia_literal(v) + elseif v isa Vector + items = [value_to_julia(x) for x in v] + return "[" * join(items, ", ") * "]" + elseif v isa String + escaped = replace(v, "\\" => "\\\\") + escaped = replace(escaped, "\"" => "\\\"") + return "\"$escaped\"" + elseif v isa Number + return string(v) + elseif v isa Bool + return string(v) + elseif v === nothing + return "nothing" + else + return "\"$(v)\"" + end +end + +""" +Convert any Julia value to a JSON string. +""" +function json_string(v)::String + return JSON.json(v) +end + +""" +Define and return the writeTool agentTool. +""" +function getTool()::agentTool + return agentTool( + name = "writeTool", + label = "Create Tool", + description = "Generate a new Julia tool module file at src/tools/.jl. After creation, restart the agent and call listTools to verify the new tool is loaded.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"), + "label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"), + "description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"), + "inputSchema" => Dict( + "type" => "object", + "description" => "JSON Schema describing tool parameters in MCP format" + ), + "executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."), + "validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."), + "prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."), + "parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools") + ), + "required" => ["name", "label", "description", "inputSchema", "executeCode"] + ), + execute = executeTool, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end diff --git a/src/type.jl b/src/type.jl index 60ae3c5..387d49c 100644 --- a/src/type.jl +++ b/src/type.jl @@ -9,7 +9,7 @@ # Message types userMessage, assistantMessage, toolResultMessage, # Tool types - agentTool, validateRequiredArgs + agentTool, validateRequiredArgs # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types