update mcp definition example
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
julia_version = "1.12.6"
|
julia_version = "1.12.6"
|
||||||
manifest_format = "2.0"
|
manifest_format = "2.0"
|
||||||
project_hash = "aa163e2bf572632825162936e107be18384fd40f"
|
project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752"
|
||||||
|
|
||||||
[[deps.Accessors]]
|
[[deps.Accessors]]
|
||||||
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
|
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
|
|||||||
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
|
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
|
||||||
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
||||||
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
||||||
LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241"
|
|
||||||
LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1"
|
LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1"
|
||||||
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
||||||
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
|
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
|
||||||
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
|
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
|
||||||
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
|
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
|
||||||
SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
|
|
||||||
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
|
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
|
||||||
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
|
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
|
||||||
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
||||||
@@ -31,7 +29,5 @@ DataFrames = "1.7.0"
|
|||||||
GeneralUtils = "0.5.10"
|
GeneralUtils = "0.5.10"
|
||||||
HTTP = "2.4.0"
|
HTTP = "2.4.0"
|
||||||
JSON = "1.6.1"
|
JSON = "1.6.1"
|
||||||
LLMMCTS = "0.1.5"
|
|
||||||
NATS = "0.1.0"
|
NATS = "0.1.0"
|
||||||
SQLLLM = "0.2.8"
|
|
||||||
msghandler = "1.2.1"
|
msghandler = "1.2.1"
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
check my understand:
|
# "tools/list" input:
|
||||||
1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and
|
mcpServer("tools/list")
|
||||||
it will be the latest message in agent._state.messages. then _agentLoop() can pick it as
|
# sending out payload before smart packed by msghandler
|
||||||
the output to outputChannel
|
Dict(
|
||||||
2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall
|
"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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ check my understanding
|
|||||||
Is my understanding correct?
|
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?
|
||||||
+3
-29
@@ -12,20 +12,12 @@ module YiemAgent
|
|||||||
include("utils.jl")
|
include("utils.jl")
|
||||||
using .utils
|
using .utils
|
||||||
|
|
||||||
include("tools/getWeather.jl")
|
|
||||||
include("tools/getTime.jl")
|
|
||||||
include("tools/searchWine.jl")
|
|
||||||
include("tools/writeTool.jl")
|
|
||||||
|
|
||||||
include("toolRegistry.jl")
|
include("toolRegistry.jl")
|
||||||
using .toolRegistry
|
using .toolRegistry
|
||||||
|
|
||||||
function register_all_tools(store::toolRegistry.toolStore)
|
function register_all_tools(store::toolRegistry.toolStore, mcpserver=nothing)
|
||||||
registerTool(store, getWeatherTool())
|
# Only register listTools — all other tools come from MCP server at runtime
|
||||||
registerTool(store, getTimeTool())
|
registerTool(store, listTool(store, mcpserver))
|
||||||
registerTool(store, searchWineTool())
|
|
||||||
registerTool(store, writeToolTool())
|
|
||||||
registerTool(store, listTool(store))
|
|
||||||
return store.tools
|
return store.tools
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -42,24 +34,6 @@ module YiemAgent
|
|||||||
# ---------------------------------------------- 100 --------------------------------------------- #
|
# ---------------------------------------------- 100 --------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+84
-3
@@ -9,9 +9,9 @@ using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serializ
|
|||||||
using GeneralUtils
|
using GeneralUtils
|
||||||
using ..type, ..utils, ..toolRegistry
|
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
|
# 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
|
end
|
||||||
|
|
||||||
# ---------------------------------------------- 100 --------------------------------------------- #
|
# ---------------------------------------------- 100 --------------------------------------------- #
|
||||||
@@ -50,6 +50,82 @@ mutable struct yiemAgent <: agent # High-level agent wrapper
|
|||||||
# The code filters for type === "toolCall" blocks, then passes them to executeToolCalls().
|
# The code filters for type === "toolCall" blocks, then passes them to executeToolCalls().
|
||||||
llmCall
|
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..)
|
# Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..)
|
||||||
beforeToolCall::Union{Function, Nothing}
|
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`)
|
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
|
||||||
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
|
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
|
||||||
- `agentEventSink::Function`: Callback to receive agent events
|
- `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
|
# Returns
|
||||||
- A new `yiemAgent` instance with an active background task
|
- A new `yiemAgent` instance with an active background task
|
||||||
@@ -106,6 +185,7 @@ function yiemAgent(
|
|||||||
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
|
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
|
||||||
parallelToolExecute::Bool=false,
|
parallelToolExecute::Bool=false,
|
||||||
agentEventSink=agentEventSink,
|
agentEventSink=agentEventSink,
|
||||||
|
mcpServer=nothing,
|
||||||
)
|
)
|
||||||
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
|
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
|
||||||
inputChannel = Channel(16)
|
inputChannel = Channel(16)
|
||||||
@@ -114,7 +194,7 @@ function yiemAgent(
|
|||||||
|
|
||||||
# load tools (statically registered at module init)
|
# load tools (statically registered at module init)
|
||||||
toolStore1 = toolStore(name="myagent")
|
toolStore1 = toolStore(name="myagent")
|
||||||
register_all_tools(toolStore1)
|
register_all_tools(toolStore1, mcpServer)
|
||||||
|
|
||||||
# Create struct with a placeholder task, then spawn and replace it
|
# Create struct with a placeholder task, then spawn and replace it
|
||||||
agent = yiemAgent(
|
agent = yiemAgent(
|
||||||
@@ -126,6 +206,7 @@ function yiemAgent(
|
|||||||
prepareContext,
|
prepareContext,
|
||||||
formatMsgForLLM,
|
formatMsgForLLM,
|
||||||
llmCall,
|
llmCall,
|
||||||
|
mcpServer,
|
||||||
beforeToolCall,
|
beforeToolCall,
|
||||||
afterToolCall,
|
afterToolCall,
|
||||||
# prepareNextTurn,
|
# prepareNextTurn,
|
||||||
|
|||||||
+176
-34
@@ -7,7 +7,7 @@ using JSON, DataStructures
|
|||||||
using ..type
|
using ..type
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Per-agent isolated tool storage.
|
Per-agent isolated tool storage.
|
||||||
|
|
||||||
Each agent gets its own `toolStore` so tool registration is independent —
|
Each agent gets its own `toolStore` so tool registration is independent —
|
||||||
`registerTool(store, tool)` only affects that agent's tool set.
|
`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)
|
toolStore(OrderedDict{String, agentTool}(), name)
|
||||||
end
|
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
|
Wrap an MCP tool definition as an `agentTool`.
|
||||||
at runtime.
|
|
||||||
|
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
|
# 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
|
# Example
|
||||||
```julia
|
```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
|
# When agent calls listTools(), tools are discovered from MCP server
|
||||||
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
|
# and registered into store.tools in real time.
|
||||||
[toolRegistry:agent1] Registered tool: listTools
|
|
||||||
|
|
||||||
julia> tools = getTools(store)
|
|
||||||
OrderedDict{String, agentTool} with 4 entries:
|
|
||||||
"getWeather" => agentTool(...)
|
|
||||||
"getTime" => agentTool(...)
|
|
||||||
"writeTool" => agentTool(...)
|
|
||||||
"listTools" => agentTool(...)
|
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
function listTool(store::toolStore)::agentTool
|
function listTool(store::toolStore, mcpserver)::agentTool
|
||||||
return agentTool(
|
return agentTool(
|
||||||
name = "listTools",
|
name="listTools",
|
||||||
label = "List Tools",
|
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.",
|
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}(
|
inputSchema=Dict{String,Any}(
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => Dict{String,Any}(),
|
"properties" => Dict{String,Any}(),
|
||||||
"required" => Any[]
|
"required" => Any[]
|
||||||
),
|
),
|
||||||
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
execute=(toolCallId::String, args::Dict{String,Any},
|
||||||
tools = getTools(store)
|
signal::Union{Nothing,abortSignal},
|
||||||
if isempty(tools)
|
onPartialResult::Function, llmCall=nothing) -> begin
|
||||||
result_text = "No tools registered."
|
# 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
|
else
|
||||||
lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools]
|
# Already discovered — just return current list
|
||||||
result_text = "Available tools:\n" * join(lines, "\n")
|
lines = String[
|
||||||
|
"- $(t.name): $(t.label) — $(t.description)"
|
||||||
|
for (k, t) in store.tools
|
||||||
|
]
|
||||||
|
result_text = "Available tools ($total):\n" * join(lines, "\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
return agentToolResult(
|
return agentToolResult(
|
||||||
[textContent(result_text)],
|
[textContent(result_text)],
|
||||||
Dict{Any,Any}("count" => length(tools)),
|
Dict{Any,Any}("count" => total),
|
||||||
nothing, false
|
nothing, false
|
||||||
)
|
)
|
||||||
end,
|
end,
|
||||||
prepareArguments = nothing,
|
prepareArguments=nothing,
|
||||||
validateRequiredArgs = nothing,
|
validateRequiredArgs=nothing,
|
||||||
parallelToolExecute = false
|
parallelToolExecute=false,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,7 +257,7 @@ Add `tool` to `store`, overwriting any existing tool with the same name.
|
|||||||
```julia
|
```julia
|
||||||
julia> store = toolStore(name="agent1");
|
julia> store = toolStore(name="agent1");
|
||||||
|
|
||||||
julia> registerTool(store, listTool(store))
|
julia> registerTool(store, listTool(store, nothing))
|
||||||
[toolRegistry:agent1] Registered tool: listTools
|
[toolRegistry:agent1] Registered tool: listTools
|
||||||
OrderedDict{String, agentTool} with 1 entry:
|
OrderedDict{String, agentTool} with 1 entry:
|
||||||
"listTools" => agentTool(...)
|
"listTools" => agentTool(...)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 = "<internal_context_for_assistant>\n<database_table_schema>\n$table_schema\n</database_table_schema>\n</internal_context_for_assistant>\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
|
|
||||||
@@ -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/<name>.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/<name>.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/<name>.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
|
|
||||||
Reference in New Issue
Block a user