diff --git a/Manifest.toml b/Manifest.toml index 0bbbd2e..dbec9cc 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "aa163e2bf572632825162936e107be18384fd40f" +project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] diff --git a/Project.toml b/Project.toml index 7fea2af..31ce449 100644 --- a/Project.toml +++ b/Project.toml @@ -12,13 +12,11 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241" LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1" NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a" PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" -SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" @@ -31,7 +29,5 @@ DataFrames = "1.7.0" GeneralUtils = "0.5.10" HTTP = "2.4.0" JSON = "1.6.1" -LLMMCTS = "0.1.5" NATS = "0.1.0" -SQLLLM = "0.2.8" msghandler = "1.2.1" diff --git a/etc.jl b/etc.jl index be4b0e2..2a3a008 100644 --- a/etc.jl +++ b/etc.jl @@ -1,5 +1,41 @@ -check my understand: -1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and -it will be the latest message in agent._state.messages. then _agentLoop() can pick it as -the output to outputChannel -2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall \ No newline at end of file +# "tools/list" input: +mcpServer("tools/list") +# sending out payload before smart packed by msghandler +Dict( + "method"=> "tools/list" +) +# expected return after smart unpacked by msghandler +Dict("tools" => [ + Dict("toolName" => "getWeather", + "title" => "Weather Lookup", + "description" => "Fetch current weather for a city.", + "inputSchema" => Dict("type"=>"object", + "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), + "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), + "required" => ["city"]) + ) +]) + +# "tools/call" input: +mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) +# sending out payload before smart packed by msghandler +Dict( + "method"=> "tools/call", + "tools"=> Dict("toolName"=>"getWeather", "arguments"=>Dict("city"=>"Tokyo", "units"=>"celsius")) +) +# expected return after smart unpacked by msghandler +Dict( + "toolName"=>"getWeather", + "content": [{"type": "text", "text": "Weather in Tokyo: Sunny, 22°C"}], + "isError": false +) + +# If an error occurs, mcpServer returns after smart unpacked by msghandler: +Dict( + "toolName"=>"getWeather", + "content": [], + "error": Dict("code"=>1, "message"=>"City not found"), + "isError": true +) + + diff --git a/etc.md b/etc.md index 4664add..9b55263 100644 --- a/etc.md +++ b/etc.md @@ -6,3 +6,6 @@ check my understanding Is my understanding correct? +the user can provide NATS connection to MCP server by adding agent.mcpserver (a callable struct) for communication with MCP server just like agent.llmCall (also a callable struct). I think communicating with MCP server is just send/receive JSON text right? +Moreover, for simplicity I want to all tools into an MCP server so an agent can be instantiated with only listTools() in tool store then populate tools from MCP server later. +what do you think? \ No newline at end of file diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 599b859..6d40a85 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -1,66 +1,40 @@ -module YiemAgent - - export register_all_tools - - """ Order by dependencies of each file. The 1st included file must not depend on any other - files and each file can only depend on the file included before it. - """ - - include("type.jl") - using .type - - include("utils.jl") - using .utils - - include("tools/getWeather.jl") - include("tools/getTime.jl") - include("tools/searchWine.jl") - include("tools/writeTool.jl") - - include("toolRegistry.jl") - using .toolRegistry - - function register_all_tools(store::toolRegistry.toolStore) - registerTool(store, getWeatherTool()) - registerTool(store, getTimeTool()) - registerTool(store, searchWineTool()) - registerTool(store, writeToolTool()) - registerTool(store, listTool(store)) - return store.tools - end - - # include("llmfunction.jl") - # using .llmfunction - - include("agentCore.jl") - using .agentCore - - include("api.jl") - using .api - - -# ---------------------------------------------- 100 --------------------------------------------- # - - - - - - - - - - - - - - - - - - - - - - - -end # module YiemAgent_v1 +module YiemAgent + + export register_all_tools + + """ Order by dependencies of each file. The 1st included file must not depend on any other + files and each file can only depend on the file included before it. + """ + + include("type.jl") + using .type + + include("utils.jl") + using .utils + + include("toolRegistry.jl") + using .toolRegistry + + function register_all_tools(store::toolRegistry.toolStore, mcpserver=nothing) + # Only register listTools — all other tools come from MCP server at runtime + registerTool(store, listTool(store, mcpserver)) + return store.tools + end + + # include("llmfunction.jl") + # using .llmfunction + + include("agentCore.jl") + using .agentCore + + include("api.jl") + using .api + + +# ---------------------------------------------- 100 --------------------------------------------- # + + + + + +end # module YiemAgent_v1 diff --git a/src/agentCore.jl b/src/agentCore.jl index e02ce2d..f0d2e14 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -9,9 +9,9 @@ using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serializ using GeneralUtils using ..type, ..utils, ..toolRegistry -function register_all_tools(store::toolRegistry.toolStore) +function register_all_tools(store::toolRegistry.toolStore, mcpServer=nothing) # Call parent module's version which has access to tool functions - parentmodule(@__MODULE__).register_all_tools(store) + parentmodule(@__MODULE__).register_all_tools(store, mcpServer) end # ---------------------------------------------- 100 --------------------------------------------- # @@ -49,7 +49,83 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Each block has a type — "text", "thinking", or "toolCall". # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). llmCall - + + # Callable struct for MCP server communication. + # Called as: mcpServer("tools/list") → returns parsed JSON dict of available tools + # mcpServer("tools/call", toolName, arguments) → returns tool result as parsed JSON dict + # + # # Example (weather tool) + # # User provides a callable struct + # struct MyMCPClient + # natsConn::NATS.Connection + # topic::String + # senderID::String + # fileserver_url::String + # end + # + # function (c::MyMCPClient)(method::String) + # payload = Dict("method"=> method) + # payloads = [("method", payload, "dictionary")] + # _, msg_envelope_json_str = msghandler.smartpack( + # c.topic, payloads; sender_id=c.senderID, + # msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url) + # reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) + # incoming_env = msghandler.smartunpack(String(reply.payload)) + # return incoming_env["payloads"][1][2] + # end + # + # function (c::MyMCPClient)(method::String, toolName::String, arguments::Dict{String,Any}) + # payload = Dict("method"=> method, "toolName"=> toolName, "arguments"=>arguments) + # payloads = [("method", payload, "dictionary"),] + # _, msg_envelope_json_str = msghandler.smartpack( + # c.topic, payloads; sender_id=c.senderID, + # msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url) + # reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) + # incoming_env = msghandler.smartunpack(String(reply.payload)) + # return incoming_env["payloads"][1][2] + # end + # + # # "tools/list" input: + # mcpServer("tools/list") + # # sending out payload before smart packed by msghandler: + # Dict("method"=> "tools/list") + # # expected return after smart unpacked by msghandler: + # Dict("tools" => [ + # Dict( + # "toolName" => "getWeather", + # "title" => "Weather Lookup", + # "description" => "Fetch current weather for a city.", + # "inputSchema" => Dict("type"=>"object", + # "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), + # "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), + # "required" => ["city"]) + # ) + # ]) + # + # # "tools/call" input: + # mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) + # # sending out payload before smart packed by msghandler: + # Dict( + # "method"=> "tools/call", + # "toolName"=>"getWeather", + # "arguments"=>Dict("city"=>"Tokyo", "units"=>"celsius") + # ) + # # expected return after smart unpacked by msghandler (success): + # Dict( + # "toolName"=> "getWeather", + # "content" => [{"type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C"}], + # "error"=> "", + # "isError" => false + # ) + # # expected return after smart unpacked by msghandler (error): + # Dict( + # "toolName"=> "getWeather", + # "content" => [], + # "error" => Dict("code"=>1, "message"=>"City not found"), + # "isError" => true + # ) + mcpServer + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} @@ -86,6 +162,9 @@ 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 +- `mcpServer`: Callable struct for MCP server communication. Called as + `mcpServer("tools/list")` to discover tools, or `mcpServer("tools/call", args)` + to execute a tool. Returns parsed JSON dicts. (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task @@ -106,6 +185,7 @@ function yiemAgent( maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, agentEventSink=agentEventSink, + mcpServer=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -114,7 +194,7 @@ function yiemAgent( # load tools (statically registered at module init) toolStore1 = toolStore(name="myagent") - register_all_tools(toolStore1) + register_all_tools(toolStore1, mcpServer) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( @@ -126,6 +206,7 @@ function yiemAgent( prepareContext, formatMsgForLLM, llmCall, + mcpServer, beforeToolCall, afterToolCall, # prepareNextTurn, diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 1d6bd52..d937d37 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -7,7 +7,7 @@ using JSON, DataStructures using ..type """ -Per-agent isolated tool storage. + 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. @@ -39,61 +39,203 @@ function toolStore(; name::String="default")::toolStore toolStore(OrderedDict{String, agentTool}(), name) end +# ── MCP helper functions ──────────────────────────────────────────── + """ - listTool(store::toolStore) -> agentTool +Extract text from MCP tool result content array. -Return an `agentTool` definition for listing registered tools. +Handles MCP's content format: [{"type":"text","text":"..."}] +""" +function _extract_text_content(result::Dict)::String + content = get(result, "content", Any[]) + if content isa Vector && !isempty(content) + lines = String[] + for block in content + if block isa Dict && get(block, "type", "") == "text" + push!(lines, string(get(block, "text", ""))) + end + end + if !isempty(lines) + return join(lines, "\n") + end + end + return JSON.json(result) +end -Each call produces a **new** tool object that captures (closes over) -`store`. `register_all_tools` auto-registers one so the LLM can discover tools -at runtime. +""" +Wrap an MCP tool definition as an `agentTool`. + +The returned tool's `execute` function calls the MCP server's "tools/call" +method with the validated arguments. +""" +function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool + name = tool_def["toolName"] + title = get(tool_def, "title", name) + desc = get(tool_def, "description", "") + input_schema = get(tool_def, "inputSchema", Dict{String,Any}()) + + # Normalize inputSchema to OpenAI function format + if haskey(input_schema, "properties") && input_schema["type"] == "object" + params = Dict( + "type" => "object", + "properties" => input_schema["properties"], + "required" => get(input_schema, "required", Any[]), + ) + else + params = Dict( + "type" => "object", + "properties" => Dict{String,Any}(), + "required" => Any[], + ) + end + + return agentTool( + name=name, + label=title, + description=desc, + inputSchema=params, + execute=(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function, + llmCall=nothing) -> begin + try + response = mcpserver("tools/call", name, args) + + result_data = get(response, "result", response) + content_text = _extract_text_content(result_data) + is_error = get(result_data, "isError", false) + + return agentToolResult( + [textContent(content_text)], + Dict{Any,Any}("isError" => is_error), + nothing, false + ) + catch e + errMsg = sprint(showerror, e) + return agentToolResult( + [textContent("MCP call error: $errMsg")], + Dict{Any,Any}("error" => errMsg), + nothing, false + ) + end + end, + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, + ) +end + +""" +Discover and register MCP tools into `store.tools`. + +Queries the MCP server via `mcpserver("tools/list")`, parses the response, +and registers each discovered tool. Skips tools already registered. +Returns `(new_count, tool_list_text)`. +""" +function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} + if mcpserver === nothing + return (0, "") + end + + try + response = mcpserver("tools/list") + tools_array = response["tools"] + + new_count = 0 + for tool_def in tools_array + name = tool_def["toolName"] + if haskey(store.tools, name) + continue + end + wrapped = _wrap_mcp_tool(mcpserver, tool_def) + store.tools[name] = wrapped + new_count += 1 + end + + # Build readable tool list + lines = String[ + "- $(t.name): $(t.label) — $(t.description)" + for (k, t) in store.tools + ] + tool_list_text = "Discovered $(new_count) MCP tools. Total registered: $(length(store.tools)).\nAvailable tools:\n" * join(lines, "\n") + + return (new_count, tool_list_text) + catch e + errMsg = sprint(showerror, e) + return (0, "MCP tools/list failed: $errMsg") + end +end + +""" + listTool(store::toolStore, mcpserver) -> agentTool + +MCP-aware listTools tool. + +First call: queries the MCP server via `mcpserver("tools/list")`, registers +all discovered tools into the shared `store.tools` (in-place mutation), +then returns the full tool list. + +Subsequent calls: returns the current list (tools remain registered). + +This is the only pre-registered tool. All other tools come from the +MCP server and are loaded at runtime when the LLM calls listTools(). # Arguments -- `store`: The tool store whose tools will be listed when the tool runs +- `store`: The tool store to populate with MCP tools +- `mcpserver`: A callable struct that communicates with the MCP server. + Called as `mcpserver("tools/list")` or `mcpserver("tools/call", args)`. + Returns parsed JSON dicts. # Example ```julia -julia> store = toolStore(name="agent1"); +# User provides an MCP server client (callable struct) +mcp = MyMCPClient("nats://localhost:4222") +store = toolStore(name="agent1") +registerTool(store, listTool(store, mcp)) -julia> register_all_tools(store) # 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(...) +# When agent calls listTools(), tools are discovered from MCP server +# and registered into store.tools in real time. ``` """ -function listTool(store::toolStore)::agentTool +function listTool(store::toolStore, mcpserver)::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}( + name="listTools", + label="List Tools", + description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.", + 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." + execute=(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function, llmCall=nothing) -> begin + # Discover and register MCP tools (idempotent — skips already registered) + new_count, tool_list = _register_mcp_tools(mcpserver, store) + + # Always include listTools itself in the count + total = length(store.tools) + + if new_count > 0 + result_text = tool_list else - lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools] - result_text = "Available tools:\n" * join(lines, "\n") + # Already discovered — just return current list + lines = String[ + "- $(t.name): $(t.label) — $(t.description)" + for (k, t) in store.tools + ] + result_text = "Available tools ($total):\n" * join(lines, "\n") end + return agentToolResult( [textContent(result_text)], - Dict{Any,Any}("count" => length(tools)), + Dict{Any,Any}("count" => total), nothing, false ) end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, ) end @@ -115,7 +257,7 @@ Add `tool` to `store`, overwriting any existing tool with the same name. ```julia julia> store = toolStore(name="agent1"); -julia> registerTool(store, listTool(store)) +julia> registerTool(store, listTool(store, nothing)) [toolRegistry:agent1] Registered tool: listTools OrderedDict{String, agentTool} with 1 entry: "listTools" => agentTool(...) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl deleted file mode 100644 index 09ea31f..0000000 --- a/src/tools/getTime.jl +++ /dev/null @@ -1,83 +0,0 @@ -using .type -using Dates - -""" -Validate required arguments for the getTime tool. - -Demonstrates custom validation beyond simple required-field checking: -- Ensures at least one time source (timezone or city) is provided -- Validates timezone is in IANA format if specified -- Validates city name is not empty if specified - -# Arguments -- `args::Dict{String,Any}`: Arguments from the LLM - -# Returns -- `nothing` if validation passes -- `String` error message if validation fails -""" -function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - hasTz = tz !== nothing && !isempty(tz) - hasCity = !isempty(city) - - # At least one of timezone or city is required - if !hasTz && !hasCity - return "Missing required argument: provide at least one of 'timezone' or 'city'" - end - - # Validate timezone format (IANA tz database: "Continent/City" or "Continent/City/SubCity") - if hasTz - tz_str = string(tz) - if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) - return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York' or 'Asia/Tokyo'" - end - end - - return nothing -end - -""" -Execute the getTime tool. - -Returns mock time data for the given timezone or city. -""" -function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult, llmCall=nothing) - 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. -""" -function getTimeTool()::agentTool - return agentTool( - name = "getTime", - label = "Time Lookup", - description = "Get current local time for a timezone or city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"), - "city" => Dict("type" => "string", "description" => "City name as fallback") - ), - "required" => [] - ), - execute = getTimeExecute, - prepareArguments = nothing, - validateRequiredArgs = getTimeValidateRequiredArgs, - parallelToolExecute = false - ) -end diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl deleted file mode 100644 index 4f73239..0000000 --- a/src/tools/getWeather.jl +++ /dev/null @@ -1,48 +0,0 @@ -using msghandler -using .type - -""" -Execute the getWeather tool. - -Returns mock weather data for the given city and temperature units. -""" -function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - agentEventSink, llmCall=nothing) - - agentEventSink("Getting weather...") - - 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. -""" -function getWeatherTool()::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, e.g., 'San Francisco, CA' or 'Tokyo, Japan'"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius", "description" => "Temperature scale") - ), - "required" => ["city"] - ), - execute = getWeatherExecute, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end diff --git a/src/tools/searchWine.jl b/src/tools/searchWine.jl deleted file mode 100644 index ca1a8fc..0000000 --- a/src/tools/searchWine.jl +++ /dev/null @@ -1,397 +0,0 @@ -using .type -using LibPQ, DataFrames, JSON, DataStructures -using Dates, Random, HTTP -using GeneralUtils - -# ── Database config — update for your environment ─────────────────────── -const DB_CONFIG = Dict{String,Any}( - "host" => "localhost", - "port" => 5432, - "dbname" => "winedb", - "user" => "postgres", - "password" => "", -) - -""" -Execute the search_wine_database! tool. - -Uses the agent's LLM to generate SQL from the free-form text query, -then executes it against the wine database and returns formatted results. -""" -function searchWineExecute( - toolCallId::String, - args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - agentEventSink, - llmCall, -)::agentToolResult - #WORKING - search_query = get(args, "searchQuery", "")::String - - if isempty(search_query) - return agentToolResult( - [textContent("Please provide a search query for the wine database.")], - Dict{Any,Any}(), nothing, false - ) - end - - agentEventSink("searchWineExecute: query=$search_query") - - # ── SQL generation prompt ─────────────────────────────────────────── - systemmsg = """ - # database_search_guidelines - - Keep SQL queries focused only on the provided information. - - Use wildcard character (%) to search more effectively. - - Do not create any table in the database. - - Text information in the database is usually stored in lower case. - If your search returns empty, try using lower case to search. - - Overly strict conditions usually yield empty results. - - Use ILIKE for case-insensitive text matching. - - Only output the SQL query — do not wrap it in backticks or add comments. - - # situation - You are a wine store database assistant. You will be given a user's - natural language search query and the database table schema. - - # objective - Generate a single SQL query to find wines matching the user's request. - - # your responsibility includes - Fulfill the objective. - - # you should respond with ONLY the SQL query string, ending with ';' - """ - - table_schema = """ - CREATE TABLE wine ( - wine_id uuid primary key default gen_random_uuid (), - wine_name varchar(128) not null, - winery varchar(128) not null, - vintage integer not null, - region varchar(128) not null, - country varchar(128) not null, - wine_type varchar(128) not null, - grape varchar(128) not null, - serving_temperature varchar(128) not null, - intensity integer, - sweetness integer, - tannin integer, - acidity integer, - fizziness integer, - tasting_notes text, - image_url jsonb, - manufacturer_sku text, - note text, - other_attributes jsonb, - created_time timestamptz default current_timestamp, - updated_time timestamptz default current_timestamp, - description text - ); - - CREATE TABLE retailer ( - retailer_id uuid primary key default gen_random_uuid (), - retailer_name varchar(128) not null, - retailer_username varchar(128) not null, - retailer_password varchar(128) not null, - retailer_address text not null, - country varchar(128) not null, - contact_person varchar(128) not null, - telephone varchar(128) not null, - email varchar(128) not null, - note text, - other_attributes jsonb, - created_time timestamptz default current_timestamp, - updated_time timestamptz default current_timestamp, - description text - ); - - CREATE TABLE retailer_wine ( - retailer_id uuid references retailer(retailer_id), - wine_id uuid references wine(wine_id), - constraint retailer_wine_id primary key (retailer_id, wine_id), - price NUMERIC(10, 2), - currency varchar(3) not null, - created_time timestamptz default current_timestamp, - updated_time timestamptz default current_timestamp - ); - """ - - context = "\n\n$table_schema\n\n\n\n" - input = context * "User query: $search_query\n\nGenerate the SQL query:" - - # ── Call LLM for SQL generation ──────────────────────────────────── - max_attempts = 5 - generated_sql = nothing - - for attempt in 1:max_attempts - msg = Dict( - "messages" => [ - Dict( - "role" => "system", - "content" => [Dict("type" => "text", "text" => systemmsg)], - ), - Dict( - "role" => "user", - "content" => [Dict("type" => "text", "text" => input)], - ), - ], - "temperature" => 0.7, - ) - - llm_response = llmCall(msg) - - # Clean the response — extract SQL from potential markdown/code blocks - sql_text = _clean_sql_response(llm_response) - - # Validate it looks like SQL - if _is_valid_sql(sql_text) - generated_sql = sql_text - agentEventSink("searchWine: generated SQL (attempt $attempt)\n$sql_text") - break - else - agentEventSink("searchWine: invalid SQL attempt $attempt: $sql_text") - end - end - - if generated_sql === nothing - return agentToolResult( - [textContent("Failed to generate a valid SQL query for your search. Please try rephrasing.")], - Dict{Any,Any}("error" => "sql_generation_failed"), nothing, false - ) - end - - # ── Execute SQL ──────────────────────────────────────────────────── - try - conn = LibPQ.Connection(DB_CONFIG) - - # Ensure LIMIT to prevent large result sets - sanitized_sql = _ensure_limit(generated_sql) - agentEventSink("searchWine: executing\n$sanitized_sql") - - result = LibPQ.execute(conn, sanitized_sql) - close(conn) - - if !LibPQ.hasdata(result) - return agentToolResult( - [textContent("No wines found matching your search. Try loosening your criteria.")], - Dict{Any,Any}("count" => 0), nothing, false - ) - end - - df = DataFrame(result) - num_rows, num_cols = size(df) - - if num_cols > 30 - return agentToolResult( - [textContent("The result has more than 30 columns. Please be more specific in your search.")], - Dict{Any,Any}("error" => "too_many_columns"), nothing, false - ) - end - - # Randomly sample up to 2 rows for display if more than 2 results - display_df = df - if num_rows > 2 - idx = sample(1:num_rows, min(2, num_rows), replace=false) - display_df = df[idx, :] - end - - # Convert to vector of dicts - result_vec = GeneralUtils.dfToVectorDict(display_df) - - # Fetch bottle images if available - for d in result_vec - image_url_json_str = get(d, "image_url", nothing) - if image_url_json_str !== nothing && !isempty(string(image_url_json_str)) - try - image_url_json_obj = JSON.parse(string(image_url_json_str)) - base_url = "http://192.168.88.106:8080/" - if haskey(image_url_json_obj, "bottle") - url = base_url * string(image_url_json_obj["bottle"]) - image_data = HTTP.get(url) - image_base64_string = base64encode(image_data.body) - d["image"] = image_base64_string - end - catch - # Skip image fetch on error - end - end - end - - # Format results as readable text - result_str = _format_wine_results(display_df) - - return agentToolResult( - [textContent(result_str)], - Dict{Any,Any}( - "count" => num_rows, - "displayed" => size(display_df, 1), - ), - nothing, false - ) - - catch e - errMsg = sprint(showerror, e) - return agentToolResult( - [textContent("Database error: $errMsg")], - Dict{Any,Any}("error" => errMsg), nothing, false - ) - end -end - -""" -Extract a SQL query string from the LLM response, handling potential -markdown code blocks, extra text, or JSON wrapping. -""" -function _clean_sql_response(response)::String - text = string(response) - - # Try to extract from code block - if occursin("```", text) - extracted = GeneralUtils.extract_triple_backtick_text(text) - if !isempty(extracted) - text = extracted[1] - # Remove "sql\n" prefix if present - if startswith(text, "sql\n") || startswith(text, "SQL\n") - text = text[5:end] - end - end - end - - # Remove JSON wrapping if present - text = strip(text) - if startswith(text, "{") && occursin("action_input", text) - # Parse as JSON and extract action_input - try - parsed = JSON.parse(text) - if parsed isa Dict - text = get(parsed, "action_input", text) - end - catch - # Keep original - end - end - - # Extract SQL keywords to find the actual query - lines = split(strip(text), '\n') - sql_lines = String[] - for line in lines - stripped = strip(line) - if occursin(r"(?i)(SELECT|FROM|WHERE|JOIN|ORDER|LIMIT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)", stripped) - # Take everything from this line to the end - push!(sql_lines, line) - elseif !isempty(sql_lines) - # Continue collecting if we already found SQL - push!(sql_lines, line) - end - end - - result = join(sql_lines, "\n") - - # Ensure it ends with semicolon - result = strip(result) - if !endswith(result, ";") - result *= ";" - end - - return result -end - -""" -Check if a string looks like a valid SQL query. -""" -function _is_valid_sql(sql::String)::Bool - sql = strip(sql) - # Must start with a SQL keyword - has_sql_keyword = occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s", sql) || - occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s*;", sql) - # Must end with semicolon - has_semicolon = endswith(sql, ";") - # Must not be too short (reject single words) - reasonable_length = length(sql) > 10 - return has_sql_keyword && has_semicolon && reasonable_length -end - -""" -Ensure the SQL query has a LIMIT clause to prevent loading excessive data. -""" -function _ensure_limit(sql::String)::String - sql = strip(sql) - if !occursin(r"(?i)LIMIT", sql) - # Remove existing semicolon, add LIMIT, re-add semicolon - if endswith(sql, ";") - sql = sql[1:end-1] - end - sql *= " ORDER BY RANDOM() LIMIT 2;" - end - return sql -end - -""" -Format wine database results as human-readable text. -""" -function _format_wine_results(df::DataFrame)::String - lines = String[] - num_rows = size(df, 1) - - for i in 1:num_rows - row = df[i, :] - push!(lines, "$(i). $(get(row, :wine_name, "Unknown")) $(get(row, :vintage, ""))") - - winery = get(row, :winery, "Unknown") - region = get(row, :region, "Unknown") - country = get(row, :country, "Unknown") - push!(lines, " Winery: $winery") - push!(lines, " Region: $region, $country") - - grape = get(row, :grape, "Unknown") - wtype = get(row, :wine_type, "Unknown") - push!(lines, " Grape: $grape") - push!(lines, " Type: $wtype") - - sweetness = get(row, :sweetness, "N/A") - intensity = get(row, :intensity, "N/A") - tannin_val = get(row, :tannin, "N/A") - acidity = get(row, :acidity, "N/A") - push!(lines, " Profile: Sweetness: $sweetness, Intensity: $intensity, Tannin: $tannin_val, Acidity: $acidity") - - tasting = get(row, :tasting_notes, nothing) - if tasting !== nothing && !isempty(string(tasting)) - tn = string(tasting) - limit = min(200, length(tn)) - push!(lines, " Notes: $(tn[1:limit])$(length(tn) > limit ? "..." : "")") - end - - price = get(row, :price, "N/A") - currency = get(row, :currency, "") - retailer = get(row, :retailer_name, "N/A") - push!(lines, " Price: $price $currency at $retailer") - push!(lines, "") - end - - return join(lines, "\n") -end - -""" -Define and return the searchWine agentTool. -""" -function searchWineTool()::agentTool - return agentTool( - name = "searchWine", - label = "Search Wine Database", - description = "Search the wine database for wines matching a free-text query. Uses the LLM to generate SQL and execute it against the database. Returns wine details including name, winery, vintage, tasting notes, and price.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "searchQuery" => Dict( - "type" => "string", - "description" => "Free-text description of the wine you're looking for, e.g., 'a light-bodied red wine from France under 50 dollars'", - ), - ), - "required" => ["searchQuery"], - ), - execute = searchWineExecute, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false, - ) -end diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl deleted file mode 100644 index 6a913b3..0000000 --- a/src/tools/writeTool.jl +++ /dev/null @@ -1,276 +0,0 @@ -using .type -using JSON - -""" -Tool that writes new Julia tool module files to disk. - -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, add the new file to `YiemAgent.jl` with an `include()` -statement (after `include("toolRegistry.jl")`), then restart the agent. -The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`. - -# Example - -1. Agent calls writeTool with a spec for a "searchWine" tool -2. writeTool generates src/tools/searchWine.jl -3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl -4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() -5. Restart agent — new tool is available - -# How It Works - -writeTool is a **file writer**, not a code generator. The LLM provides the -tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: - - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string - - Indents `executeCode` with 4 spaces - - Wraps it inside `function executeTool(...)::agentToolResult ... end` - - Appends `writeToolTool()` returning an `agentTool` struct - - Writes the combined string to `src/tools/.jl` - -# 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 - -""" -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 writeToolTool()::agentTool - return agentTool( - name = "writeTool", - label = "Create Tool", - description = "Write a new Julia tool module file to src/tools/.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.", - 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 = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult, llmCall=nothing) -> begin - 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 must be included in YiemAgent.jl and registered in register_all_tools() - write(filepath, tool_code) - - onPartialResult(Dict("status" => "Done")) - - return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")], - Dict{Any,Any}( - "file" => filepath, - "name" => tool_name, - "label" => tool_label, - "description" => tool_description, - ), - nothing, false - ) - end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end