Files
YiemAgent/src/utils.jl
T
2026-08-17 03:04:10 +07:00

614 lines
17 KiB
Julia

module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
beforeToolCall, afterToolCall, agentEventSink
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
using GeneralUtils
using ..type
# ---------------------------------------------- 100 --------------------------------------------- #
"""
Clear agent chat history.
Empties the conversation history, short-term memory, events log, and chatbox.
# Arguments
- `a::T`: An agent instance (subtype of `agent`)
# Returns
- `nothing`
# Notes
- Does not clear long-term memory; use `[PENDING] clear memory` when implemented.
# Examples
```jldoctest
julia> YiemAgent.clearhistory(agent)
```
"""
function clearhistory(a::T) where {T<:agent}
# empty!(a.chathistory)
# empty!(a.memory["shortmem"])
# empty!(a.memory["events"])
# a.memory["chatbox"] = ""
end
"""
Convert a vector of wine dictionaries to a formatted text string.
# Arguments
- `vecd::Vector`: A vector of dictionaries, each representing a wine with key-value pairs
# Returns
- A formatted string where each wine is numbered and each key-value pair is comma-separated
in the format: `"1) key1:value1,key2:value2 key3:value3 2) ..."`
# Examples
```jldoctest
julia> vecd = [Dict("wine_name" => "Chateau A", "price" => "50")]
julia> YiemAgent.availableWineToText(vecd)
"1) wine_name:Chateau A,price:50 "
```
"""
function availableWineToText(vecd::Vector)::String
# Initialize an empty string to hold the final text
rowtext = ""
# Loop through each dictionary in the input vector
for (i, d) in enumerate(vecd)
# Iterate over all key-value pairs in the dictionary
temp = []
for (k, v) in d
# Append the formatted string to the text variable
t = "$k:$v"
push!(temp, t)
end
_rowtext = join(temp, ',')
rowtext *= "$i) $_rowtext "
end
return rowtext
end
"""
prepareContext(state::agentState) -> agentContext
Prepares an `agentContext` from the given `agentState` for sending to
the LLM. By default, it deep copies the system prompt, messages, and
tools from `state` into a new `agentContext`.
Override this function to customize the context — such as filtering
tools based on the user's intent, modifying the system prompt, injecting
additional context (retrieved documents, current time, user preferences),
or pruning and reordering messages before formatting and calling the LLM.
# Arguments
- `state::agentState`: The current agent state containing conversation history,
system prompt, tools, and other configuration
# Returns
- `agentContext`: An `agentContext` containing the prepared system prompt,
messages, and tools to be sent to the LLM
# Examples
```julia
# Default: returns an agentContext with deep copies of system prompt, messages, and tools
prepareContext(state).messages == deepcopy(state.messages)
# Override to filter tools and inject system context:
# function prepareContext(state::agentState)
# msgs = deepcopy(state.messages)
# sysPrompt = state.systemPrompt * "\\nCurrent time: $(now())"
# tools = filter(t -> contains(t.description, "wine"), state.tools)
# return agentContext(sysPrompt, msgs, tools)
# end
```
"""
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
#TODO add filtered tools to the current system prompt / modify systemPrompt here
preparedSystemPrompt = state.systemPrompt
#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, llmCall)
return agentCtx
end
"""
formatMsgForLLM(ctx::agentContext) -> Dict{String, Any}
Converts an `agentContext` into OpenAI-compatible message format
ready to be sent to the LLM. The system prompt is converted into
a system role message, followed by user, assistant, and tool result
messages.
This function can be overridden in `yiemAgent` to produce custom
LLM message formats for different APIs/providers.
# Arguments
- `ctx::agentContext`: The prepared context containing system prompt,
messages, and tools
# Returns
- `Dict{String, Any}`: A dictionary with `"messages"` key containing
an array of OpenAI-format message dicts
# Examples
```julia
# Default output:
formatMsgForLLm(ctx) == Dict("messages" => [
Dict("role" => "system", "content" => [...]),
Dict("role" => "user", "content" => [...]),
Dict("role" => "assistant", "content" => [...]),
Dict("role" => "tool", "tool_call_id" => "...", "content" => [...]),
])
```
"""
function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
""" openai message format example
msg = Dict(
"model" => "gemma-4-E4B-it-UD-Q4_K_XL",
"messages" => [
Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => systemmsg),
]
),
Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "Do you have something similar to the one in the image?"),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data_uri)
)
]
),
Dict(
"role" => "assistant",
"content" => [
Dict("type" => "text", "text" => "let me check."),
]
),
],
"tools"=> [
Dict(
"type" => "function",
"function" => Dict(
"name" => "getWeather",
"description" => "Get current weather",
"parameters" => Dict(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string")
),
"required" => ["city"]
)
)
)
],
"temperature" => 0.7
)
"""
openaiReadyMsg = Dict{String, Any}()
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
messages = Vector{Dict{String, Any}}()
agentEventSink("formatMsgForLLM 1")
# System prompt as system message
if !isempty(ctx.systemPrompt)
push!(messages, Dict(
"role" => "system",
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
))
end
agentEventSink("formatMsgForLLM 2")
# Conversation messages
for msg in ctx.messages
if msg isa userMessage
push!(messages, _userMessageToOpenAI(msg))
elseif msg isa assistantMessageToolCall
push!(messages, _assistantMessageToolCallToOpenAI(msg))
elseif msg isa assistantMessage
push!(messages, _assistantMessageToOpenAI(msg))
elseif msg isa toolResultMessage
push!(messages, _toolResultMessageToOpenAI(msg))
end
end
agentEventSink("formatMsgForLLM 3")
# Convert ctx.tools into OpenAI tools format
tools_array = _toolsToOpenAI(ctx.tools, agentEventSink)
agentEventSink("formatMsgForLLM 4")
openaiReadyMsg["messages"] = messages
openaiReadyMsg["temperature"] = 0.7
if !isempty(tools_array)
openaiReadyMsg["tools"] = tools_array
end
return openaiReadyMsg
end
"""
beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult
Callback invoked before executing a tool call. Use this hook to inspect
the tool call and decide whether to allow, block, or modify it.
Common use cases:
- Request user approval via UI before running destructive tools.
- Validate business rules that cannot be expressed in the JSON schema.
- Check final context (e.g. session state, rate limits, permissions).
# Arguments
- `context::beforeToolCallContext`: Contains the assistant message, tool call,
validated arguments, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `beforeToolCallResult(false, "N/A")` to allow the call to proceed.
- `beforeToolCallResult(true, "Reason")` to block the call with a reason.
- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`).
# Example
```julia
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)
if context.toolCall.name == "deleteFile"
# Block file deletion unless explicitly approved
return beforeToolCallResult(true, "User must approve file deletion")
end
return beforeToolCallResult(false, "N/A")
end
```
"""
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
)::beforeToolCallResult
# final context check
# seek user approval via UI
# other check
return beforeToolCallResult(false, "N/A")
end
"""
afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}
Callback invoked after a tool call finishes executing (before and after errors).
Use this hook to post-process the tool result before it is fed back to the LLM.
Common use cases:
- Mask sensitive data (API keys, tokens) from result content.
- Normalize usage tracking data into a consistent format.
- Inspect the result and set `terminate: true` based on business logic
(e.g. "if deployment failed, stop the agent rather than retrying").
- Wrap error results in friendlier messages for the LLM to understand.
# Arguments
- `context::afterToolCallContext`: Contains the assistant message, tool call,
arguments, raw result, error status, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `nothing` to pass the result through unchanged.
- `agentToolResult(...)` to return a modified result (content, details, usage,
terminate flag can all be overridden).
"""
function afterToolCall(context::afterToolCallContext, signal::abortSignal
)::Union{agentToolResult, Nothing}
# modify context.result if needed and return agentToolResult
return nothing
end
#TODO
function agentEventSink(x)
end
"""
Convert a userMessage to OpenAI message format.
"""
function _userMessageToOpenAI(msg::userMessage)::Dict{String, Any}
return Dict(
"role" => "user",
"content" => _messageContentToBlocks(msg.content)
)
end
"""
Convert an assistantMessageToolCall to OpenAI message format.
Produces a message with role="assistant", content=null, and a tool_calls array:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\"}"
}
}
]
}
"""
function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{String, Any}
tool_calls = Dict{String, Any}[]
for tc in msg.toolCalls
push!(tool_calls, Dict(
"id" => tc.id,
"type" => tc.type,
"function" => Dict(
"name" => tc.name,
"arguments" => JSON.json(tc.arguments)
)
))
end
return Dict(
"role" => "assistant",
"content" => nothing,
"tool_calls" => tool_calls
)
end
"""
Convert an assistantMessage to OpenAI message format.
"""
function _assistantMessageToOpenAI(msg::assistantMessage)::Dict{String, Any}
return Dict(
"role" => "assistant",
"content" => _messageContentToBlocks(msg.content)
)
end
"""
Convert a toolResultMessage to OpenAI message format.
"""
function _toolResultMessageToOpenAI(msg::toolResultMessage)::Dict{String, Any}
return Dict(
"role" => "tool",
"tool_call_id" => msg.toolCallId,
"content" => _messageContentToBlocks(msg.content)
)
end
"""
Convert a vector of messageContent to OpenAI content blocks.
Each textContent becomes a text block, each imageContent becomes
an image_url block.
"""
function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{String, Any}}
blocks = Vector{Dict{String, Any}}()
for c in contents
if c isa textContent
push!(blocks, Dict("type" => "text", "text" => c.text))
elseif c isa imageContent
push!(blocks, Dict(
"type" => "image_url",
"image_url" => Dict(
"url" => "data:$(c.mimeType);base64,$(c.data)"
)
))
end
end
return blocks
end
"""
_toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}) -> Vector{Dict{String, Any}}
Convert an OrderedDict of agentTool definitions into OpenAI function tool format.
Returns an empty vector when `tools` is `nothing` or empty.
# Examples
```julia
_toolsToOpenAI(nothing) # => Dict{String, Any}[]
_toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))]
```
"""
function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, agentEventSink)::Vector{Dict{String, Any}}
tools_array = Vector{Dict{String, Any}}()
agentEventSink("_toolsToOpenAI 1")
agentEventSink(string(typeof(tools)))
if tools !== nothing
for (_, tool) in tools
push!(tools_array, Dict(
"type" => "function",
"function" => Dict(
"name" => tool.name,
"description" => tool.description,
"parameters" => tool.inputSchema
)
))
end
end
agentEventSink("_toolsToOpenAI 2")
return tools_array
end
"""
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
Validates that all required fields listed in the tool's JSON Schema are present
in `args`. Returns `nothing` if validation passes, or a descriptive error string
listing the missing required fields.
This is the default `validateRequiredArgs` hook. Tool authors can override it
with a custom validation function that performs additional checks (e.g. type
coercion, format validation, cross-field constraints).
# Arguments
- `args::Dict{String,Any}`: The arguments provided by the LLM
- `inputSchema::Dict{String,Any}`: The tool's `inputSchema` (JSON Schema format)
# Returns
- `nothing` if all required args are present
- `String` error message listing missing fields otherwise
# Examples
```julia
schema = Dict("required" => ["city"])
args = Dict{String,Any}()
validateRequiredArgs(args, schema) # => "Missing required arguments: city"
args2 = Dict("city" => "Tokyo")
validateRequiredArgs(args2, schema) # => nothing
```
"""
function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String}
required = get(inputSchema, "required", Any[])
if isempty(required)
return nothing
end
missing = String[]
for field in required
if !(field in keys(args))
push!(missing, field)
end
end
if !isempty(missing)
return "Missing required arguments: $(join(missing, ", "))"
end
return nothing
end
"""
validateToolArguments(tool::agentTool, prepared::agentToolCall) -> Dict{String,Any}
Validates the prepared tool call arguments by calling the tool's
`validateRequiredArgs` hook (or the default implementation). If validation
fails, returns a modified `agentToolCall` with an empty arguments dict
so downstream code can detect the failure. If the hook exists on the tool
and returns an error string, that error is returned.
This runs **before** the `beforeToolCall` hook, allowing the agent to
reject invalid calls without invoking lifecycle callbacks or logging
false `toolExecutionStart` events.
# Arguments
- `tool::agentTool`: The resolved tool definition
- `prepared::agentToolCall`: The prepared tool call with potentially transformed arguments
# Returns
- `Dict{String,Any}`: The validated arguments if successful
# Errors
- Throws `ArgumentError` if validation fails — this is caught by `prepareToolCall`
and converted to an `immediateOutcome`
# Examples
```julia
# With validateRequiredArgs hook set on the tool
validateToolArguments(toolWithHook, tc) # => validated args or throws
# With default validation (nothing on tool)
validateToolArguments(toolDefault, tc) # => args or throws
```
"""
function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{String,Any}
# Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only)
if isnothing(tool.validateRequiredArgs)
result = validateRequiredArgs(prepared.arguments, tool.inputSchema)
else
result = tool.validateRequiredArgs(prepared.arguments)
end
if result !== nothing
throw(ArgumentError(result))
end
return prepared.arguments
end
end # module util