614 lines
17 KiB
Julia
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 |