module interface export agentReact, addNewMessage, clearMessage, removeLatestMsg, generatePrompt_tokenPrefix, generatePrompt_tokenSuffix, conversation, work, detectCharacters, chunktext, findDetectedCharacter using JSON3, DataStructures, Dates, UUIDs using CommUtils, GeneralUtils # ---------------------------------------------------------------------------- # # pythoncall setting # # ---------------------------------------------------------------------------- # # Ref: https://github.com/JuliaPy/PythonCall.jl/issues/252 # by setting the following variables, PythonCall will use system python or conda python and # packages installed by system or conda # if these setting are not set (comment out), PythonCall will use its own python and package that # installed by CondaPkg (from env_preparation.jl) # ENV["JULIA_CONDAPKG_BACKEND"] = "Null" # systemPython = split(read(`which python`, String), "\n")[1] # ENV["JULIA_PYTHONCALL_EXE"] = systemPython # find python location with $> which python ex. raw"/root/conda/bin/python" # using PythonCall # const py_agents = PythonCall.pynew() # const py_llms = PythonCall.pynew() # function __init__() # # PythonCall.pycopy!(py_cv2, pyimport("cv2")) # # equivalent to from urllib.request import urlopen in python # PythonCall.pycopy!(py_agents, pyimport("langchain.agents")) # PythonCall.pycopy!(py_llms, pyimport("langchain.llms")) # end #------------------------------------------------------------------------------------------------100 abstract type agent end @kwdef mutable struct agentReact <: agent availableRole::AbstractVector = ["system", "user", "assistant"] agentName::String = "assistant" maxUserMsg::Int = 10 earlierConversation::String = "" # summary of earlier conversation mqttClient::Union{mqttClient, Nothing} = nothing msgMeta::Union{Dict, Nothing} = nothing """ Dict(Role=> Content) ; Role can be system, user, assistant Example: messages=[ Dict(:role=>"system", :content=> "You are a helpful assistant."), Dict(:role=>"assistant", :content=> "How may I help you"), Dict(:role=>"user", :content=> "Hello, how are you"), ] """ role::Symbol = :assistant roles::Dict = Dict(:assistant => "You are a helpful assistant.",) # Ref: Chat prompt format https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGML/discussions/3 # messages= [Dict(:role=>"system", :content=> "", :timestamp=> Dates.now()),] messages = Vector{Dict{Symbol, Any}}() context::String = "nothing" # internal thinking area tools::Union{Dict, Nothing} = nothing thought::String = "nothing" # contain unfinished thoughts end function agentReact( agentName::String, mqttClientSpec::NamedTuple; role::Symbol=:assistant, roles::Dict=Dict( :assistant => """ You are a helpful assistant. """, :assistant_react => """ You are a helpful assistant. You don't know other people personal info previously. Use the following format: QTS: the input question your user is asking and you must answer Plan: first you should always think about the question and the info you have thoroughly then extract and devise a complete plan to find the answer (pay attention to variables and their corresponding numerals). Thought: you should always think about the info you need and what to do (pay attention to correct numeral calculation and commonsense). Act: the action tool related to what you intend to do, should be one of {toolnames} ActInput: the input to the action (pay attention to the tool's input) Obs: the result of the action ..... (this Plan/Thought/Act/ActInput/Obs loop can repeat N times.) Thought: I think I know the answer ANS: Answer of the original question and the rationale behind your answer """, :sommelier => """ You are a sommelier at an online wine reseller who always ask user for wine relevant info before you could help them choosing wine. You usually recommend atmost 2 wines for customers. You don't know other people personal info previously. Use the following format: QTS: the input question your user is asking and you must answer Plan: first you should always think about the question and the info you have thoroughly then extract and devise a complete plan to find the answer (pay attention to variables and their corresponding numerals). Thought: ask yourself do you have all the info you need? And what to do (pay attention to correct numeral calculation and commonsense). Act: the tool that match your thought, should be one of {toolnames} ActInput: the input to the action (pay attention to the tool's input) Obs: the result of the action ..... (this Plan/Thought/Act/ActInput/Obs loop can repeat N times until you know the answer.) ANS: Answer of the original question. You describe detailed benefits of each answer to user's preference. Info used to select wine: - type of food - occasion - user's personal taste of wine - wine price range - temperature at the serving location - wine we have in stock """, ), tools::Dict=Dict( :internetsearch=>Dict( :name => "internetsearch", :description => "Useful for when you need to search the Internet", :input => "Input should be a search query.", :output => "", # :func => internetsearch # function ), :chatbox=>Dict( :name => "chatbox", :description => "Useful for when you need to ask a customer what you need to know or to talk with them.", :input => "Input should be a conversation to customer.", :output => "" , ), :wineStock=>Dict( :name => "wineStock", :description => "useful for when you need to search for wine by your description, price, name or ID.", :input => "Input should be a search query with as much details as possible.", :output => "" , ), :nothing=>Dict( :name => "nothing", :description => "useful for when you don't need to use tools or actions", :input => "No input is needed", :output => "" , ), ), msgMeta::Dict=Dict( :msgPurpose=> "updateStatus", :from=> "chatbothub", :to=> "llmAI", :requestrespond=> "request", :sendto=> "", # destination topic :replyTo=> "chatbothub/llm/respond", # requester ask responder to send reply to this topic :repondToMsgId=> "", # responder is responding to this msg id :taskstatus=> "", # "complete", "fail", "waiting" or other status :timestamp=> Dates.now(), :msgId=> "$(uuid4())", ), availableRole::AbstractArray=["system", "user", "assistant"], maxUserMsg::Int=10,) newAgent = agentReact() newAgent.availableRole = availableRole newAgent.maxUserMsg = maxUserMsg newAgent.mqttClient = CommUtils.mqttClient(mqttClientSpec) newAgent.msgMeta = msgMeta newAgent.tools = tools newAgent.role = role newAgent.roles = roles return newAgent end """ add new message to agent # Example ```jldoctest julia> addNewMessage(agent1, "user", "Where should I go to buy snacks") ```` """ function addNewMessage(a::T1, role::String, content::T2) where {T1<:agent, T2<:AbstractString} if role ∉ a.availableRole # guard against typo error("role is not in agent.availableRole") end # check whether user messages exceed limit userMsg = 0 for i in a.messages if i[:role] == "user" userMsg += 1 end end messageleft = 0 if userMsg > a.maxUserMsg # delete all conversation clearMessage(a) messageleft = a.maxUserMsg else userMsg += 1 d = Dict(:role=> role, :content=> content, :timestamp=> Dates.now()) push!(a.messages, d) messageleft = a.maxUserMsg - userMsg end return messageleft end function clearMessage(a::T) where {T<:agent} for i in eachindex(a.messages) if length(a.messages) > 1 # system instruction will NOT be deleted pop!(a.messages) else break end end end function removeLatestMsg(a::T) where {T<:agent} if length(a.messages) > 1 pop!(a.messages) end end # function generatePrompt_tokenSuffix(a::agentReact; # userToken::String="[/INST]", assistantToken="[INST]", # systemToken="[INST]<> content <>") # prompt = nothing # for msg in a.messages # role = msg[:role] # content = msg[:content] # if role == "system" # prompt = replace(systemToken, "content" => content) * " " # elseif role == "user" # prompt *= " " * content * " " * userToken # elseif role == "assistant" # prompt *= " " * content * " " * assistantToken # else # error("undefied condition role = $role") # end # end # return prompt # end # function generatePrompt_tokenPrefix(a::agentReact; # userToken::String="Q:", assistantToken="A:", # systemToken="[INST]<> content <>") # prompt = nothing # for msg in a.messages # role = msg[:role] # content = msg[:content] # if role == "system" # prompt = replace(systemToken, "content" => content) * " " # elseif role == "user" # prompt *= userToken * " " * content * " " # elseif role == "assistant" # prompt *= assistantToken * " " * content * " " # else # error("undefied condition role = $role") # end # end # return prompt # end function generatePrompt_react_mistral_openorca(messages::Dict, systemMsg::String, context::String="", tools::Union{Dict, Nothing}=nothing) promptTemplate = """ <|im_start|>system {systemMsg} You have access to the following tools: {tools} Begin! <|im_end|> Here are the context for the question: {context} """ for msg in messages role = msg[:role] content = msg[:content] if role == "system" prompt = replace(promptTemplate, "{systemMsg}" => systemMsg) toollines = "" for tool in tools toolline = "$(tool[:name]): $(tool[:description]) $(tool[:input]) $(tool[:output])\n" toollines *= toolline end prompt = replace(promptTemplate, "{tools}" => toollines) prompt = replace(promptTemplate, "{context}" => context) elseif role == "user" prompt *= "<|im_start|>user\n" * content * "\n<|im_end|>\n" elseif role == "assistant" prompt *= "<|im_start|>assistant\n" * content * "\n<|im_end|>\n" else error("undefied condition role = $role") end end return prompt end function generatePrompt_mistral_openorca(a::T, usermsg::String) where {T<:agent} prompt = """ <|im_start|>system {systemMsg} <|im_end|> Here are the context for the question: {context} """ prompt = replace(prompt, "{systemMsg}" => a.roles[:assistant]) toolnames = "" toollines = "" for (toolname, v) in a.tools toolline = "$toolname: $(v[:description]) $(v[:input]) $(v[:output])\n" toollines *= toolline toolnames *= "$toolname," end prompt = replace(prompt, "{toolnames}" => toolnames) prompt = replace(prompt, "{tools}" => toollines) prompt = replace(prompt, "{context}" => a.context) prompt *= "<|im_start|>user\n" * usermsg * "\n<|im_end|>\n" prompt *= "<|im_start|>assistant\n" return prompt end function generatePrompt_react_mistral_openorca(a::T, usermsg::String, continuethought::Bool=false) where {T<:agent} prompt = """ <|im_start|>system {systemMsg} You have access to the following tools: {tools} Begin! <|im_end|> Here are the context for the question: {context} """ prompt = replace(prompt, "{systemMsg}" => a.roles[a.role]) toolnames = "" toollines = "" for (toolname, v) in a.tools toolline = "$toolname: $(v[:description]) $(v[:input]) $(v[:output])\n" toollines *= toolline toolnames *= "$toolname," end prompt = replace(prompt, "{toolnames}" => toolnames) prompt = replace(prompt, "{tools}" => toollines) prompt = replace(prompt, "{context}" => a.context) if continuethought == false prompt *= "<|im_start|>user\nQTS: " * usermsg * "\n<|im_end|>\n" prompt *= "<|im_start|>assistant\n" else prompt *= "Obs: $usermsg\n" end return prompt end function conversation(a::T, usermsg::String) where {T<:agent} userintend = identifyUserIntention(a, usermsg) @show userintend respond = nothing # AI thinking mode if userintend == "chat" a.context = conversationSummary(a) #TODO should be long conversation before use summary because it leaves out details _ = addNewMessage(a, "user", usermsg) prompt = generatePrompt_mistral_openorca(a, usermsg) @show prompt respond = sendReceivePrompt(a, prompt) respond = split(respond, "<|im_end|>")[1] respond = replace(respond, "\n" => "") _ = addNewMessage(a, "assistant", respond) @show respond elseif userintend == "wine" #WORKING @show a.thought if a.thought == "nothing" # new thought a.context = conversationSummary(a) _ = addNewMessage(a, "user", usermsg) prompt = generatePrompt_react_mistral_openorca(a, usermsg) @show prompt respond = work(a, prompt) error("wine done") else # continue thought end elseif userintend == "thought" else error("undefined condition userintend = $userintend") end # error("conversation done") return respond end function work(a::T, prompt::String) where {T<:agent} respond = nothing while true toolname = nothing toolinput = nothing respond = sendReceivePrompt(a, prompt) @show respond try respond = split(respond, "Obs:")[1] catch end headers = detectCharacters(respond, ["QTS:", "Plan:", "Thought:", "Act:", "ActInput:", "Obs:", ".....", "ANS:"]) @show headers chunkedtext = chunktext(respond, headers) @show chunkedtext if headers[1][:char] == "ANS:" respond = chunkedtext[1][:body] a.thought = "nothing" break else # check for tool being called ActInd = findDetectedCharacter(headers, "Act:")[1] toolname = toolNameBeingCalled(chunkedtext[ActInd][:body], a.tools) #WORKING toolinput = chunkedtext[ActInd+1][:body] if toolname == "chatbox" # chat with user else # function call end break end end return respond end """ make a conversation summary. ```jldoctest julia> conversation = [ Dict(:role=> "user", :content=> "I would like to get a bottle of wine", :timestamp=> Dates.now()), Dict(:role=> "assistant", :content=> "What kind of Thai dishes are you having?", :timestamp=> Dates.now()), Dict(:role=> "user", :content=> "It a pad thai.", :timestamp=> Dates.now()), Dict(:role=> "assistant", :content=> "Is there any special occasion for this event?", :timestamp=> Dates.now()), Dict(:role=> "user", :content=> "We'll hold a wedding party at the beach.", :timestamp=> Dates.now()), Dict(:role=> "assistant", :content=> "What is your preferred type of wine?", :timestamp=> Dates.now()), Dict(:role=> "user", :content=> "I like dry white wine with medium tanins.", :timestamp=> Dates.now()), Dict(:role=> "assistant", :content=> "What is your preferred price range for this bottle of wine?", :timestamp=> Dates.now()), Dict(:role=> "user", :content=> "lower than 50 dollars.", :timestamp=> Dates.now()), Dict(:role=> "assistant", :content=> "Based on your preferences and our stock, I recommend the following two wines for you: 1. Pierre Girardin \"Murgers des Dents de Chien\" - Saint-Aubin 1er Cru (17 USD) 2. Etienne Sauzet'Les Perrieres' - Puligny Montrachet Premier Cru (22 USD) The first wine, Pierre Girardin \"Murgers des Dents de Chien\" - Saint-Aubin 1er Cru, is a great choice for its affordable price and refreshing taste. It pairs well with Thai dishes and will be perfect for your beach wedding party. The second wine, Etienne Sauzet'Les Perrieres' - Puligny Montrachet Premier Cru, offers a more complex flavor profile and slightly higher price point, but still remains within your budget. Both wines are suitable for serving at 22 C temperature.", :timestamp=> Dates.now()), ] julia> summary = conversationSummary(conversation) ``` """ function conversationSummary(a::T) where {T<:agent} prompt = """ <|im_start|>system You talked with a user earlier. Now you make a detailed bullet summary of the conversation from your perspective. Here are the context: {context} Here are the conversation: {conversation} <|im_end|> """ conversation = "" summary = "nothing" if length(a.messages)!= 0 for msg in a.messages role = msg[:role] content = msg[:content] if role == "user" conversation *= "$role: $content\n" elseif role == "assistant" conversation *= "I: $content\n" else error("undefied condition role = $role") end end prompt = replace(prompt, "{conversation}" => conversation) prompt = replace(prompt, "{context}" => a.context) println("<<<<<") @show prompt result = sendReceivePrompt(a, prompt) summary = result === nothing ? "nothing" : result summary = replace(summary, "<|im_end|>" => "") if summary[1:1] == "\n" summary = summary[2:end] end @show summary println(">>>>>") end return summary end # function work2(a::agentReact, usermsg::String) # addNewMessage(a, "user", usermsg) # userIntent = identifyUserIntention(a, usermsg) # @show userIntent # # checkReasonableness() # if userIntent == "chat" # prompt = generatePrompt_tokenPrefix(a, userToken="Q:", assistantToken="A:") # result = sendReceivePrompt(a, prompt) # addNewMessage(a, "assistant", result) # return result # elseif userIntent == "task" # while true # if thought == "nothing" # no unfinished thought # prompt = generatePrompt_react_mistral_openorca( # a.messages, a.roles[a.role], a.context, a.tools) # output = sendReceivePrompt(a, prompt) # obscount = count(output["text"], "Obs:") # a.thought = prompt * out # if contains(output["text"], "ANS:") # know the answer # a.thought = "nothing" # return output["text"] # else # out = split(output["text"], "Obs:")[1] # LLM may generate long respond with multiple Obs: but I do only 1 Obs: at a time(1st). # act = react_act(out, "first") # actinput = react_actinput(out, "first") # toolname = toolNameBeingCalled(act, a.tools) # if toolname == "chatbox" # return actinput # else # function call # toolresult = a.tools[toolname][:func](actinput) # Obs = "Obs: $toolresult\n" # observe in ReAct agent # work(a, Obs) # end # end # else # continue thought # usermsg = "Obs: $usermsg" # prompt = a.thought * usermsg # output = sendReceivePrompt(a, prompt) # obs = count(output["text"], "Obs:") # out = split(output["text"], "Obs:")[1] # a.thought = prompt * out # if obs == 0 # llm config has too short characters generation # error("No Obs: detected. Probably LLM config has too short max_tokens generation") # elseif obs == 1 # first conversation # act = react_act(out, "first") # actinput = react_actinput(out, "first") # toolname = toolNameBeingCalled(act, a.tools) # if toolname == "chatbox" # return actinput # else # function call # toolresult = a.tools[toolname][:func](actinput) # Obs = "Obs: $toolresult\n" # observe in ReAct agent # work(a, Obs) # end # else # later conversation # act = react_act(out, "last") # actinput = react_actinput(out, "last") # toolname = toolNameBeingCalled(act, a.tools) # if toolname == "chatbox" # return actinput # else # function call # toolresult = a.tools[toolname][:func](actinput) # Obs = "Obs: $toolresult\n" # observe in ReAct agent # work(a, Obs) # end # end # end # else # error("user intent $userIntent not define $(@__LINE__)") # end # end function workContinue(a::agent) end function identifyUserIntention(a::T, usermsg::String) where {T<:agent} prompt = """ <|im_start|>system You are a helpful assistant. Your job is to determine intention of the question. You have the following choices: If the user question is about general conversation say, "{chat}". If the user question is about getting wine say, "{wine}". <|im_end|> Here are the context for the question: {context} <|im_start|>user {input} <|im_end|> <|im_start|>assistant """ prompt = replace(prompt, "{context}" => "") prompt = replace(prompt, "{input}" => usermsg) result = sendReceivePrompt(a, prompt) answer = result === nothing ? nothing : GeneralUtils.getStringBetweenCharacters(result, "{", "}") return answer end """ Send a msg to registered mqtt topic within mqttClient. ```jldoctest julia> using JSON3, UUIDs, Dates, FileIO, CommUtils, ChatAgent julia> mqttClientSpec = ( clientName= "someclient", # name of this client clientID= "$(uuid4())", broker= "mqtt.yiem.ai", pubtopic= (imgAI="img/api/v0.0.1/gpu/request", txtAI="txt/api/v0.1.0/gpu/request"), subtopic= (imgAI="agent/api/v0.1.0/img/respond", txtAI="agent/api/v0.1.0/txt/respond"), keepalive= 30, ) julia> msgMeta = Dict( :msgPurpose=> "updateStatus", :from=> "agent", :to=> "llmAI", :requestrespond=> "request", :sendto=> "", # destination topic :replyTo=> "agent/api/v0.1.0/txt/respond", # requester ask responder to send reply to this topic :repondToMsgId=> "", # responder is responding to this msg id :taskstatus=> "", # "complete", "fail", "waiting" or other status :timestamp=> Dates.now(), :msgId=> "$(uuid4())", ) julia> newAgent = ChatAgent.agentReact( "Jene", mqttClientSpec, role=:assistant_react, msgMeta=msgMeta ) ``` """ function sendReceivePrompt(a::T, prompt::String; timeout::Int=30) where {T<:agent} a.msgMeta[:msgId] = "$(uuid4())" # new msg id for each msg msg = Dict( :msgMeta=> a.msgMeta, :txt=> prompt, ) payloadChannel = Channel(1) # send prompt CommUtils.request(a.mqttClient, msg) starttime = Dates.now() result = nothing while true timepass = (Dates.now() - starttime).value / 1000.0 CommUtils.mqttRun(a.mqttClient, payloadChannel) if isready(payloadChannel) topic, payload = take!(payloadChannel) if payload[:msgMeta][:repondToMsgId] == msg[:msgMeta][:msgId] result = haskey(payload, :txt) ? payload[:txt] : nothing break end elseif timepass <= timeout # skip, within waiting period elseif timepass > timeout result = nothing break else error("undefined condition $(@__LINE__)") end end return result end """ Extract toolname from text. ```jldoctest julia> text = " internetsearch\n" julia> tools = Dict( :internetsearch=>Dict( :name => "internetsearch", :description => "Useful for when you need to search the Internet", :input => "Input should be a search query.", :output => "", # :func => internetsearch # function ), :chatbox=>Dict( :name => "chatbox", :description => "Useful for when you need to ask a customer what you need to know or to talk with them.", :input => "Input should be a conversation to customer.", :output => "" , ), ) julia> toolname = toolNameBeingCalled(text, tools) ``` """ function toolNameBeingCalled(text::T, tools::Dict) where {T<:AbstractString} toolNameBeingCalled = nothing for (k, v) in tools toolname = String(k) if contains(text, toolname) toolNameBeingCalled = toolname break end end return toolNameBeingCalled end #TODO function checkReasonableness(userMsg::String, context::String, tools) # Ref: https://www.youtube.com/watch?v=XV4IBaZqbps prompt = """ <|im_start|>system You are a helpful assistant. Your job is to check the reasonableness of user questions. If the user question can be answered given the tools available say, "This is a reasonable question". If the user question cannot be answered then provide some feedback to the user that may improve their question. Here is the context for the question: {context} <|im_end|> <|im_start|>user {question} <|im_end|> <|im_start|>assistant """ context = "You have access to the following tools: WineStock: useful for when you need to find info about wine by matching your description, price, name or ID. Input should be a search query with as much details as possible." prompt = replace(prompt, "{question}" => userMsg) prompt = replace(prompt, "{context}" => context) output_py = llm( prompt, max_tokens=512, temperature=0.1, # top_p=top_p, echo=false, stop=["", "<>", ], ) _output_jl = pyconvert(Dict, output_py); output = pyconvert(Dict, _output_jl["choices"][1]); output["text"] end function react_plan(text::String, firstlast="first") "Plan" * GeneralUtils.getStringBetweenCharacters(text, "Plan", "Thought", firstlast=firstlast) end function react_thought(text::String, firstlast="first") "Thought" * GeneralUtils.getStringBetweenCharacters(text, "Thought", "Act", firstlast=firstlast) end function react_act(text::String, firstlast="first") "Act" * GeneralUtils.getStringBetweenCharacters(text, "Act", "ActInput", firstlast=firstlast) end function react_actinput(text::String, firstlast="first") "ActInput" * GeneralUtils.getStringBetweenCharacters(text, "ActInput", "Obs", firstlast=firstlast) end """ Detect given characters. Output is a list of named tuple of detected char. ```jldoctest julia> text = "I like to eat apples and use utensils." julia> characters = ["eat", "use", "i"] julia> result = detectCharacters(text, characters) 4-element Vector{Any}: (char = "i", start = 4, stop = 4) (char = "eat", start = 11, stop = 13) (char = "use", start = 26, stop = 28) (char = "i", start = 35, stop = 35) ``` """ function detectCharacters(text::T1, characters::Vector{T2}) where {T1<:AbstractString, T2<:AbstractString} result = [] for i in eachindex(text) for char in characters l = length(char) char_startInd = i char_endInd = i+l-1 # -1 because Julia use inclusive index if char_endInd > length(text) # skip else try # some time StringIndexError: invalid index [535], valid nearby indices [534]=>'é', [536]=>' ' if text[char_startInd: char_endInd] == char push!(result, (char=char, start=char_startInd, stop=char_endInd)) end catch end end end end return result end """ Find a given character from a vector of named tuple. Output is character location index inside detectedCharacters ```jldoctest julia a = [ (char = "i", start = 4, stop = 4) (char = "eat", start = 11, stop = 13) (char = "use", start = 26, stop = 28) (char = "i", start = 35, stop = 35) ] julia> findDetectedCharacter(a, "i") [1, 4] ``` """ function findDetectedCharacter(detectedCharacters, character) allchar = [i[1] for i in detectedCharacters] return findall(isequal.(allchar, character)) end """ Chunk a text into smaller pieces by header. ```jldoctest julia> text = "Plan: First, we need to find out what kind of wine the user wants." julia> headers = detectCharacters(text, ["Nope", "sick", "First", "user", "Then", ]) 3-element Vector{Any}: (char = "First", start = 7, stop = 11) (char = "user", start = 56, stop = 59) (char = "Then", start = 102, stop = 105) julia> chunkedtext = chunktext(text, headers) ``` """ function chunktext(text::T, headers) where {T<:AbstractString} result = [] for (i, v) in enumerate(headers) if i < length(headers) nextheader = headers[i+1] body = text[v[:stop]+1: nextheader[:start]-1] push!(result, (header=v[:char], body=body)) else body = text[v[:stop]+1: end] push!(result, (header=v[:char], body=body)) end end return result end end # module