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