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