This commit is contained in:
2026-08-17 03:04:10 +07:00
parent c7a98f1710
commit 5829c82d05
11 changed files with 418 additions and 3713 deletions
+2
View File
@@ -14,6 +14,7 @@ module YiemAgent
include("tools/getWeather.jl")
include("tools/getTime.jl")
include("tools/searchWine.jl")
include("tools/writeTool.jl")
include("toolRegistry.jl")
@@ -22,6 +23,7 @@ module YiemAgent
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
+12 -8
View File
@@ -5,7 +5,7 @@ export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls,
executeToolCallsParallel, executeToolCalls
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames, Base.Threads, NATS
DataFrames, Base.Threads, NATS, LibPQ
using GeneralUtils
using ..type, ..utils, ..toolRegistry
@@ -379,7 +379,7 @@ function _processMessage(
# call prepareContext()
state = agentState(systemPrompt, nothing, tools, agentMsgHistory)
agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))")
preparedContext = prepareContext(state, agentEventSink)
preparedContext = prepareContext(state, agentEventSink, llmCall)
agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))")
# Call formatMessagesForLLM() to format for LLM
formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink)
@@ -409,6 +409,7 @@ function _processMessage(
beforeToolCall,
afterToolCall,
parallelToolExecute ? "parallel" : "sequential",
llmCall,
)
signal = abortSignal(false)
@@ -1000,13 +1001,14 @@ function executePreparedToolCall(
prep::preparedToolCall,
signal::Union{Nothing,abortSignal},
agentEventSink,
llmCall::Union{Any,Nothing}=nothing,
)::executedOutcome
agentEventSink("executePreparedToolCall 1")
agentEventSink("executePreparedToolCall 2")
agentEventSink("executePreparedToolCall 3")
try
result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink)
result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink, llmCall)
agentEventSink(result.content[1].text)
agentEventSink("executePreparedToolCall 4")
return executedOutcome(result, false)
@@ -1184,6 +1186,7 @@ function executeToolCallsSequential(
signal::abortSignal,
agentEventSink,
)::agentToolCallBatch
llmCall = config.llmCall
agentEventSink("executeToolCallsSequential 1")
finalizedCalls = finalizedOutcome[]
messages = toolResultMessage[]
@@ -1199,8 +1202,7 @@ function executeToolCallsSequential(
agentEventSink("executeToolCallsSequential 2-2")
else
agentEventSink("executeToolCallsSequential 3")
#XXX
executed = executePreparedToolCall(prep, signal, agentEventSink)
executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall)
agentEventSink("executeToolCallsSequential 3-1")
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config,
signal, agentEventSink)
@@ -1287,6 +1289,7 @@ function executeToolCallsParallel(
)::agentToolCallBatch
entries = Union{finalizedOutcome,Task}[]
llmCall = config.llmCall
for tc in toolCalls
agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments))
@@ -1300,7 +1303,7 @@ function executeToolCallsParallel(
push!(entries, finalized)
else
t = Task() do
executed = executePreparedToolCall(prep, signal, agentEventSink)
executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
@@ -1388,6 +1391,7 @@ function executeToolCalls(
agentEventSink,
)::agentToolCallBatch
llmCall = config.llmCall
agentEventSink("_executeToolCalls 1")
hasSequential = false
for tc in toolCalls
@@ -1400,11 +1404,11 @@ function executeToolCalls(
agentEventSink("_executeToolCalls 2")
if config.toolExecution == "sequential" || hasSequential
agentEventSink("_executeToolCalls 3")
return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal,
return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal,
agentEventSink)
else
agentEventSink("_executeToolCalls 4")
return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal,
return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal,
agentEventSink)
end
end
+1 -1
View File
@@ -45,7 +45,7 @@ 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)
onPartialResult, llmCall=nothing)
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
if tz !== nothing
+1 -1
View File
@@ -7,7 +7,7 @@ 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)
agentEventSink, llmCall=nothing)
agentEventSink("Getting weather...")
+397
View File
@@ -0,0 +1,397 @@
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 -1
View File
@@ -130,7 +130,7 @@ function writeToolTool()::agentTool
),
"required" => ["name", "label", "description", "inputSchema", "executeCode"]
),
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin
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
+2
View File
@@ -358,6 +358,7 @@ struct agentContext # Snapshot of the agent's conversa
systemPrompt::String # System prompt for the agent
messages::Vector{agentMessage} # Conversation messages
tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
llmCall::Union{Any, Nothing} # LLM call function (for tools that need it)
end
@@ -451,6 +452,7 @@ struct agentLoopConfig
beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing}
toolExecution::String
llmCall::Union{Any, Nothing} # LLM call function (for tools like searchWine)
end
"""
+2 -2
View File
@@ -109,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages)
# end
```
"""
function prepareContext(state::agentState, agentEventSink)::agentContext
function prepareContext(state::agentState, agentEventSink, llmCall=nothing)::agentContext
#TODO filter tools from state.tools based on user intend in user message and tool description
filteredTools = state.tools
@@ -120,7 +120,7 @@ function prepareContext(state::agentState, agentEventSink)::agentContext
#TODO add system prompt, adjust/modify and inject additional context into messages
preparedMessages = deepcopy(state.messages) # messages that will be send to LLM
agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools)
agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools, llmCall)
return agentCtx
end