update mcp definition example
This commit is contained in:
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# "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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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?
|
||||
+40
-66
@@ -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
|
||||
|
||||
+85
-4
@@ -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,
|
||||
|
||||
+176
-34
@@ -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(...)
|
||||
|
||||
@@ -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