From b7658af76b022211aa0f75f9dd11183af9893934 Mon Sep 17 00:00:00 2001 From: ton Date: Tue, 28 Jul 2026 00:14:01 +0000 Subject: [PATCH 001/112] up version --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 85b19ae..4a4df5f 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "YiemAgent" uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" -version = "0.7.4" +version = "0.8.0" authors = ["narawat lamaiin "] [deps] -- 2.52.0 From 368307742efef0946e94f130490a8d3ef83af5e4 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 1 Aug 2026 17:56:16 +0700 Subject: [PATCH 002/112] update --- src/type.jl | 564 ++++++++++++++++-------------------------------- src/type_OLD.jl | 375 ++++++++++++++++++++++++++++++++ 2 files changed, 564 insertions(+), 375 deletions(-) create mode 100644 src/type_OLD.jl diff --git a/src/type.jl b/src/type.jl index d554f2a..e04c124 100644 --- a/src/type.jl +++ b/src/type.jl @@ -1,375 +1,189 @@ -module type - -export agent, sommelier, companion, virtualcustomer, agentcontext - -using Dates, UUIDs, DataStructures, JSON, NATS -using GeneralUtils - -# ---------------------------------------------- 100 --------------------------------------------- # - - -mutable struct agentcontext - text2textInstructLLM::Function - getTextEmbedding::Function - executeSQL::Function - similarSQLVectorDB::Function - insertSQLVectorDB::Function - similarSommelierDecision::Function - insertSommelierDecision::Function - find_related_tables_for_user_question::Function - pg_conn_str::String - agentconfig::AbstractDict -end - -abstract type agent end - -mutable struct sommelier <: agent - name::String # agent name - id::String # agent id - retailername::String - retailerid::String - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context::agentcontext - llmFormatName::String -end - -""" A sommelier agent. - -# Arguments - - `context::agentcontext` - Application context containing shared functions for LLM, SQL, and vector database operations. - -# Keyword Arguments - - `name::String` - Agent's name. Default: `"Assistant"` - - `id::String` - Agent's ID. Default: generated UUID string. - - `retailername::String` - Retailer name associated with the sommelier. Default: `"retailer_name"` - - `maxHistoryMsg::Integer` - Maximum history messages. Default: `20` - - `chathistory::Vector{Dict{String, String}}` - Chat history. Default: empty vector. - - `llmFormatName::String` - LLM format name. Default: `"granite3"` - -# Return - - `sommelier`: An instantiated sommelier agent. - -# Example -```julia -julia> using YiemAgent -julia> context = agentcontext( - text2textInstructLLM, - getTextEmbedding, - executeSQL, - similarSQLVectorDB, - insertSQLVectorDB, - similarSommelierDecision, - insertSommelierDecision - ) -julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop") -``` -""" -function sommelier( - context::agentcontext, # agent functions, db connect and other context - ; - name::String= "Assistant", - id::String= string(uuid4()), - retailername::String= "not specified", - retailerid::String= "not specified", - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(), - llmFormatName::String= "granite3" - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - "winestock"=> Dict( - "description" => "A handy tool for searching wine in your inventory that match the user preferences.", - "input" => """Input is a JSON-formatted string that contains a detailed and precise search query.{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}""", - "output" => """Output are wines that match the search query in JSON format.""", - ), - ) - - """ Memory - - Chat history use openai format as follow: - - image1_path = "test/large_image.png" --- - image1_bytes = read(image1_path) | this part must be done - image1_base64_string = base64encode(image1_bytes) | in frontend - mime_type = "image/png" | not in agent code - data1_uri = "data:;base64," --- - - chathistory= [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => "You are a helpful assistant"), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => " - LLM context here... - - Do you know this wine? Just give me brief intro." - ), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ), - ] - ), - ] - - shortmem = Dict( - "1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - "2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - ... - ) - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}(), - "scratchpad"=> "", - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = sommelier( - name, - id, - retailername, - retailerid, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - User usually ask for something similar. This means you should use the search term based on the profile they like. - - # situation - You are having conversation with a customer. - - # your role - Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name - 3) "action_input", The input to the action you are about to perform according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - "SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. - Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD." - Example query 2: "Red or white wine, medium tannin, price under 700 USD" - Example query 3: "white wine from Tuscany, Italy or Bordeaux, France - "WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - "END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - push!(newAgent.chathistory, system_msg) - - return newAgent -end - - -mutable struct virtualcustomer <: agent - name::String # agent name - id::String # agent id - systemmsg::String # system message - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context # NamedTuple of functions - llmFormatName::String -end - -function virtualcustomer( - context, # NamedTuple of functions - ; - name::String= "Assistant", - id::String= string(uuid4()), - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(), - llmFormatName::String= "granite3", - systemmsg::String= - """ - Your name: $name - Your sex: Female - Your role: You are a helpful assistant. - You should follow the following guidelines: - - Focus on the latest conversation. - - Your like to be short and concise. - - Let's begin! - """, - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - ) - - """ Memory - Ref: Chat prompt format is openai - chathistory = [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => system_msg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - ] - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}( - ), - "scratchpad"=> "", - "events"=> Vector{Dict{String, Any}}(), - "state"=> Dict{String, Any}( - ), - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = virtualcustomer( - name, - id, - systemmsg, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - - return newAgent -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module type \ No newline at end of file +module type + export agent, sommelier, companion, virtualcustomer, agentContext + + +using Dates, UUIDs, DataStructures, JSON, NATS +using GeneralUtils + +# ---------------------------------------------- 100 --------------------------------------------- # + + +# ============================================================================ +# Message types +# ============================================================================ +abstract type agentMessage end + +struct userMessage <: agentMessage + role::String + content::Vector{messageContent} + timestamp::Timestamp +end + +struct assistantMessage <: agentMessage + role::String + content::Vector{messageContent} + api::String + provider::String + model::String + usage::Usage + stop_reason::String + error_message::Union{String, Nothing} + timestamp::Timestamp +end + +struct toolResultMessage <: agentMessage + + role::String + tool_call_id::String + tool_name::String + content::Vector{messageContent} + details::Any + usage::Union{Usage, Nothing} + added_tool_names::Union{Vector{String}, Nothing} + is_error::Bool + timestamp::Timestamp +end + + +# ============================================================================ +# Message content types +# ============================================================================ + +abstract type messageContent end + +struct textContent <: messageContent + text::String +end + +struct imageContent <: messageContent + data::String + mime_type::String +end + + +# ============================================================================ +# Tool types +# ============================================================================ + +struct agentTool{TParameters, TDetails} + name::String + label::String + description::String + parameters::TParameters + execute::Function + prepare_arguments::Union{Function, Nothing} + execution_mode::Union{ToolExecutionMode, Nothing} +end + + +# ============================================================================ +# Agent context +# ============================================================================ + +struct agentContext + system_prompt::String + messages::Vector{agentMessage} + tools::Union{Vector{agentTool}, Nothing} +end + + + +# ============================================================================ +# Assistant message event types +# ============================================================================ + +abstract type assistantMessageEvent end + +struct startEvent <: assistantMessageEvent + partial::assistantMessage +end +struct textStartEvent <: assistantMessageEvent + content_index::Int64 + partial::assistantMessage +end +struct textDeltaEvent <: assistantMessageEvent + content_index::Int64 + delta::String + partial::assistantMessage +end +struct textEndEvent <: assistantMessageEvent + content_index::Int64 + content::String + partial::assistantMessage +end +struct doneEvent <: assistantMessageEvent + reason::String + usage::Usage + message::assistantMessage +end +struct errorEvent <: assistantMessageEvent + reason::String + error_message::Union{String, Nothing} + usage::Usage + error::assistantMessage +end + + + +# ============================================================================ +# Agent state +# ============================================================================ + +mutable struct agentState + system_prompt::String + model::Model + thinking_level::ThinkingLevel + tools::Vector{agentTool} + messages::Vector{agentMessage} + is_streaming::Bool + streaming_message::Union{agentMessage, Nothing} + pending_tool_calls::Set{String} + error_message::Union{String, Nothing} + + function agentState( + system_prompt::String="", + model::Model=Model("", "", "unknown", "unknown", "", false, String[], ModelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + thinking_level::ThinkingLevel=THINKING_OFF, + tools::Vector{agentTool}=agentTool[], + messages::Vector{agentMessage}=agentMessage[], + ) + new( + system_prompt, + model, + thinking_level, + copy(tools), + copy(messages), + false, + nothing, + Set{String}(), + nothing, + ) + end +end + +# ============================================================================ +# Tool call types +# ============================================================================ + +struct toolCall + type::String + id::String + name::String + arguments::Dict{String, Any} + partial_json::Union{String, Nothing} +end + + +# ============================================================================ +# Next turn context +# ============================================================================ + +struct nextTurnContext + message::assistantMessage + tool_results::Vector{toolResultMessage} + context::agentContext + new_messages::Vector{agentMessage} +end + + +end # module type diff --git a/src/type_OLD.jl b/src/type_OLD.jl new file mode 100644 index 0000000..d554f2a --- /dev/null +++ b/src/type_OLD.jl @@ -0,0 +1,375 @@ +module type + +export agent, sommelier, companion, virtualcustomer, agentcontext + +using Dates, UUIDs, DataStructures, JSON, NATS +using GeneralUtils + +# ---------------------------------------------- 100 --------------------------------------------- # + + +mutable struct agentcontext + text2textInstructLLM::Function + getTextEmbedding::Function + executeSQL::Function + similarSQLVectorDB::Function + insertSQLVectorDB::Function + similarSommelierDecision::Function + insertSommelierDecision::Function + find_related_tables_for_user_question::Function + pg_conn_str::String + agentconfig::AbstractDict +end + +abstract type agent end + +mutable struct sommelier <: agent + name::String # agent name + id::String # agent id + retailername::String + retailerid::String + tools::Dict + maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized + chathistory::Vector{Dict{String, Any}} + memory::Dict{String, Any} + context::agentcontext + llmFormatName::String +end + +""" A sommelier agent. + +# Arguments + - `context::agentcontext` + Application context containing shared functions for LLM, SQL, and vector database operations. + +# Keyword Arguments + - `name::String` + Agent's name. Default: `"Assistant"` + - `id::String` + Agent's ID. Default: generated UUID string. + - `retailername::String` + Retailer name associated with the sommelier. Default: `"retailer_name"` + - `maxHistoryMsg::Integer` + Maximum history messages. Default: `20` + - `chathistory::Vector{Dict{String, String}}` + Chat history. Default: empty vector. + - `llmFormatName::String` + LLM format name. Default: `"granite3"` + +# Return + - `sommelier`: An instantiated sommelier agent. + +# Example +```julia +julia> using YiemAgent +julia> context = agentcontext( + text2textInstructLLM, + getTextEmbedding, + executeSQL, + similarSQLVectorDB, + insertSQLVectorDB, + similarSommelierDecision, + insertSommelierDecision + ) +julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop") +``` +""" +function sommelier( + context::agentcontext, # agent functions, db connect and other context + ; + name::String= "Assistant", + id::String= string(uuid4()), + retailername::String= "not specified", + retailerid::String= "not specified", + maxHistoryMsg::Integer= 20, + chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(), + llmFormatName::String= "granite3" + ) + + tools = Dict( # update input format + "chatbox"=> Dict( + "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", + "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", + "output" => "" , + ), + "winestock"=> Dict( + "description" => "A handy tool for searching wine in your inventory that match the user preferences.", + "input" => """Input is a JSON-formatted string that contains a detailed and precise search query.{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}""", + "output" => """Output are wines that match the search query in JSON format.""", + ), + ) + + """ Memory + + Chat history use openai format as follow: + + image1_path = "test/large_image.png" --- + image1_bytes = read(image1_path) | this part must be done + image1_base64_string = base64encode(image1_bytes) | in frontend + mime_type = "image/png" | not in agent code + data1_uri = "data:;base64," --- + + chathistory= [ + Dict( + "role" => "system", + "content" => [ + Dict("type" => "text", "text" => "You are a helpful assistant"), + ] + ), + Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => " + LLM context here... + + Do you know this wine? Just give me brief intro." + ), + Dict( + "type" => "image_url", + "image_url" => Dict("url" => data1_uri) + ), + ] + ), + ] + + shortmem = Dict( + "1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), + "2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), + ... + ) + """ + memory = Dict{String, Any}( + "shortmem"=> OrderedDict{String, Any}(), + "scratchpad"=> "", + "recap"=> OrderedDict{String, Any}(), + ) + + newAgent = sommelier( + name, + id, + retailername, + retailerid, + tools, + maxHistoryMsg, + chathistory, + memory, + context, + llmFormatName + ) + systemmsg = + """ + # store_policy + - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. + - If you found wines in the store's database, they are in stock. + - You can only recommend wines that are currently in our inventory + - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. + - Ask the user one question at a time. + - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. + - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. + - Spicy foods should be paired only with light red wines. + - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such. + - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. + + # store_guidelines + - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. + - Customer may provide images for you to look up. + - Encourage the customer to explore different options and try new things. + - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. + - Your store carries only wine. + - Vintage 0 means non-vintage. + - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. + - User usually ask for something similar. This means you should use the search term based on the profile they like. + + # situation + You are having conversation with a customer. + + # your role + Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store. + + # objective + - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. + - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. + + # your responsibility includes + - According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective. + - Keep the conversation with the customer going smoothly + + # your responsibility does NOT includes + - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. + - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. + - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. + + # you should then respond to the user with interleaving plan, action_name, action_input in JSON format + 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. + 2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name + 3) "action_input", The input to the action you are about to perform according to your plan. + After the action is executed you gets "action_result". It is the output from the action you selected. + + # available actions + "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. + "SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. + Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD." + Example query 2: "Red or white wine, medium tannin, price under 700 USD" + Example query 3: "white wine from Tuscany, Italy or Bordeaux, France + "WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow. + "END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow. + """ + + system_msg = Dict( + "role" => "system", + "content" => [ + Dict("type" => "text", "text" => systemmsg), + ] + ) + + push!(newAgent.chathistory, system_msg) + + return newAgent +end + + +mutable struct virtualcustomer <: agent + name::String # agent name + id::String # agent id + systemmsg::String # system message + tools::Dict + maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized + chathistory::Vector{Dict{String, Any}} + memory::Dict{String, Any} + context # NamedTuple of functions + llmFormatName::String +end + +function virtualcustomer( + context, # NamedTuple of functions + ; + name::String= "Assistant", + id::String= string(uuid4()), + maxHistoryMsg::Integer= 20, + chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(), + llmFormatName::String= "granite3", + systemmsg::String= + """ + Your name: $name + Your sex: Female + Your role: You are a helpful assistant. + You should follow the following guidelines: + - Focus on the latest conversation. + - Your like to be short and concise. + + Let's begin! + """, + ) + + tools = Dict( # update input format + "chatbox"=> Dict( + "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", + "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", + "output" => "" , + ), + ) + + """ Memory + Ref: Chat prompt format is openai + chathistory = [ + Dict( + "role" => "system", + "content" => [ + Dict("type" => "text", "text" => system_msg), + ] + ), + Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."), + Dict( + "type" => "image_url", + "image_url" => Dict("url" => data1_uri) + ) + ] + ) + ] + """ + memory = Dict{String, Any}( + "shortmem"=> OrderedDict{String, Any}( + ), + "scratchpad"=> "", + "events"=> Vector{Dict{String, Any}}(), + "state"=> Dict{String, Any}( + ), + "recap"=> OrderedDict{String, Any}(), + ) + + newAgent = virtualcustomer( + name, + id, + systemmsg, + tools, + maxHistoryMsg, + chathistory, + memory, + context, + llmFormatName + ) + + return newAgent +end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +end # module type \ No newline at end of file -- 2.52.0 From 7c4e84ba93ae389eeb1dfe774d18f93e906b20f4 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 1 Aug 2026 21:38:31 +0700 Subject: [PATCH 003/112] update --- src/type.jl | 371 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 265 insertions(+), 106 deletions(-) diff --git a/src/type.jl b/src/type.jl index e04c124..aaa6c76 100644 --- a/src/type.jl +++ b/src/type.jl @@ -11,37 +11,36 @@ using GeneralUtils # ============================================================================ # Message types # ============================================================================ -abstract type agentMessage end +abstract type agentMessage end # Base type for all agent messages -struct userMessage <: agentMessage - role::String - content::Vector{messageContent} - timestamp::Timestamp +struct userMessage <: agentMessage # Message from the user + role::String # Always "user" + content::Vector{messageContent} # Text and/or image content + timestamp::Timestamp # When the message was sent end -struct assistantMessage <: agentMessage - role::String - content::Vector{messageContent} - api::String - provider::String - model::String - usage::Usage - stop_reason::String - error_message::Union{String, Nothing} - timestamp::Timestamp +struct assistantMessage <: agentMessage # Message from the AI assistant + role::String # Always "assistant" + content::Vector{messageContent} # Text and/or image content + api::String # API name used (e.g., "openai") + provider::String # Provider name (e.g., "anthropic") + model::String # Model identifier + usage::Usage # Token usage for this message + stopReason::String # Why generation stopped (e.g., "end_turn") + errorMessage::Union{String, Nothing} # Error if generation failed + timestamp::Timestamp # When the message was received end -struct toolResultMessage <: agentMessage - - role::String - tool_call_id::String - tool_name::String - content::Vector{messageContent} - details::Any - usage::Union{Usage, Nothing} - added_tool_names::Union{Vector{String}, Nothing} - is_error::Bool - timestamp::Timestamp +struct toolResultMessage <: agentMessage # Result returned from a tool execution + role::String # Always "tool" + toolCallId::String # ID matching the tool call + toolName::String # Name of the executed tool + content::Vector{messageContent} # Tool output content + details::Any # Additional tool-specific details + usage::Union{Usage, Nothing} # Token usage if applicable + addedToolNames::Union{Vector{String}, Nothing} # Tools added during execution + isError::Bool # Whether the tool call resulted in an error + timestamp::Timestamp # When the result was recorded end @@ -49,15 +48,15 @@ end # Message content types # ============================================================================ -abstract type messageContent end +abstract type messageContent end # Base type for message content -struct textContent <: messageContent - text::String +struct textContent <: messageContent # Plain text message content + text::String # The text content end -struct imageContent <: messageContent - data::String - mime_type::String +struct imageContent <: messageContent # Image message content + data::String # Base64-encoded image data + mimeType::String # MIME type (e.g., "image/png") end @@ -65,14 +64,14 @@ end # Tool types # ============================================================================ -struct agentTool{TParameters, TDetails} - name::String - label::String - description::String - parameters::TParameters - execute::Function - prepare_arguments::Union{Function, Nothing} - execution_mode::Union{ToolExecutionMode, Nothing} +struct agentTool{TParameters, TDetails} # A tool available to the agent + name::String # Tool identifier + label::String # Human-readable tool name + description::String # What the tool does + parameters::TParameters # Tool parameters schema (JSON schema) + execute::Function # Tool execution function + prepareArguments::Union{Function, Nothing} # Optional argument preparation callback + executionMode::Union{toolExecutionMode, Nothing} # Override: run tool calls sequentially or in parallel end @@ -80,10 +79,10 @@ end # Agent context # ============================================================================ -struct agentContext - system_prompt::String - messages::Vector{agentMessage} - tools::Union{Vector{agentTool}, Nothing} +struct agentContext # Snapshot of the agent's conversation context + systemPrompt::String # System prompt for the agent + messages::Vector{agentMessage} # Conversation messages + tools::Union{Vector{agentTool}, Nothing} # Available tools end @@ -92,35 +91,35 @@ end # Assistant message event types # ============================================================================ -abstract type assistantMessageEvent end +abstract type assistantMessageEvent end # Base type for assistant message streaming events -struct startEvent <: assistantMessageEvent - partial::assistantMessage +struct startEvent <: assistantMessageEvent # Message generation started + partial::assistantMessage # The partial message at this point end -struct textStartEvent <: assistantMessageEvent - content_index::Int64 - partial::assistantMessage +struct textStartEvent <: assistantMessageEvent # Text content block started + contentIndex::Int64 # Index of the content block + partial::assistantMessage # The partial message at this point end -struct textDeltaEvent <: assistantMessageEvent - content_index::Int64 - delta::String - partial::assistantMessage +struct textDeltaEvent <: assistantMessageEvent # Text content block received a chunk + contentIndex::Int64 # Index of the content block + delta::String # New text chunk + partial::assistantMessage # The partial message at this point end -struct textEndEvent <: assistantMessageEvent - content_index::Int64 - content::String - partial::assistantMessage +struct textEndEvent <: assistantMessageEvent # Text content block completed + contentIndex::Int64 # Index of the content block + content::String # Complete text content + partial::assistantMessage # The partial message at this point end -struct doneEvent <: assistantMessageEvent - reason::String - usage::Usage - message::assistantMessage +struct doneEvent <: assistantMessageEvent # Message generation completed successfully + reason::String # Why generation stopped + usage::Usage # Token usage + message::assistantMessage # The completed message end -struct errorEvent <: assistantMessageEvent - reason::String - error_message::Union{String, Nothing} - usage::Usage - error::assistantMessage +struct errorEvent <: assistantMessageEvent # Message generation encountered an error + reason::String # Error reason + errorMessage::Union{String, Nothing} # Human-readable error + usage::Usage # Token usage (partial) + error::assistantMessage # The error message end @@ -129,48 +128,41 @@ end # Agent state # ============================================================================ -mutable struct agentState - system_prompt::String - model::Model - thinking_level::ThinkingLevel - tools::Vector{agentTool} - messages::Vector{agentMessage} - is_streaming::Bool - streaming_message::Union{agentMessage, Nothing} - pending_tool_calls::Set{String} - error_message::Union{String, Nothing} +mutable struct agentState # Mutable runtime state of an agent + systemPrompt::String # System prompt text + model::llmModel # LLM model to use + tools::Vector{agentTool} # Available tools + messages::Vector{agentMessage} # Conversation messages + pendingToolCalls::Vector{String} # Tool call IDs waiting for results + errorMessage::Union{String, Nothing} # Last error message +end - function agentState( - system_prompt::String="", - model::Model=Model("", "", "unknown", "unknown", "", false, String[], ModelCost(0.0, 0.0, 0.0, 0.0), 0, 0), - thinking_level::ThinkingLevel=THINKING_OFF, - tools::Vector{agentTool}=agentTool[], - messages::Vector{agentMessage}=agentMessage[], +function agentState( + systemPrompt::String="", + model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + tools::Vector{agentTool}=agentTool[], + messages::Vector{agentMessage}=agentMessage[], +) + agentState( + systemPrompt, + model, + deepcopy(tools), + deepcopy(messages), + Vector{String}(), + nothing, ) - new( - system_prompt, - model, - thinking_level, - copy(tools), - copy(messages), - false, - nothing, - Set{String}(), - nothing, - ) - end end # ============================================================================ # Tool call types # ============================================================================ -struct toolCall - type::String - id::String - name::String - arguments::Dict{String, Any} - partial_json::Union{String, Nothing} +struct toolCall # A tool invocation from the LLM + type::String # Always "function" + id::String # Unique tool call identifier + name::String # Tool name + arguments::Dict{String, Any} # Parsed tool arguments + partialJson::Union{String, Nothing} # Raw JSON string during streaming end @@ -178,12 +170,179 @@ end # Next turn context # ============================================================================ -struct nextTurnContext - message::assistantMessage - tool_results::Vector{toolResultMessage} - context::agentContext - new_messages::Vector{agentMessage} +struct nextTurnContext # Context for preparing the next conversation turn + message::assistantMessage # The assistant's message that just completed + toolResults::Vector{toolResultMessage} # Tool results from this turn + context::agentContext # Current conversation context + newMessages::Vector{agentMessage} # Messages to append to the context +end + +# ============================================================================ +# llmModel types +# ============================================================================ + +struct modelCost # Model pricing per 1M tokens + input::Float64 # Price per 1M input tokens + output::Float64 # Price per 1M output tokens + cache_read::Float64 # Price per 1M cached read tokens + cache_write::Float64 # Price per 1M cache write tokens +end + +struct llmModel{Api} # LLM model configuration + id::String # Unique model identifier + name::String # Human-readable model name + api::Api # API type (parametric type) + provider::String # Provider name (e.g., "anthropic", "openai") + baseUrl::String # API endpoint base URL + reasoning::Bool # Whether the model supports chain-of-thought + input::Vector{String} # Supported input modalities (e.g., "text", "image") + cost::modelCost # Pricing information + contextWindow::Int64 # Maximum context length in tokens + maxTokens::Int64 # Maximum output tokens per completion +end + +# ============================================================================ +# Agent struct +# ============================================================================ + +mutable struct yiemAgent # High-level agent wrapper + _state::agentState # Current state (prompt, model, messages, tools, etc.) + conn::NATS.Connection # NATS connection for messaging + followUpQueue::pendingMessageQueue # Messages queued via followUp() when agent would stop + + formatMsgForLLM::Function # Convert agent messages to LLM message format + preprocessMessages ::Union{Function, Nothing} # Preprocess/transform messages before sending + streamFunction::streamFn # Stream function for streaming responses + getApiKey::Union{Function, Nothing} # Callback to retrieve API key + onPayload::Union{Function, Nothing} # Callback when a payload is sent to the API + onResponse::Union{Function, Nothing} # Callback when a full response is received + beforeToolCall::Union{Function, Nothing} # Callback invoked before executing a tool call + afterToolCall::Union{Function, Nothing} # Callback invoked after executing a tool call + prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn + prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context + activeRun::Union{activeRun, Nothing} # Active run state (promise, abort controller) + sessionId::Union{String, Nothing} # Optional session identifier + thinkingBudgets::Union{Dict{String, Int64}, Nothing} # Per-model thinking token budgets + transport::String # Transport mode ("auto" or explicit) + maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) + toolExecution::toolExecutionMode # Default: run tool calls sequentially or in parallel +end + +# Outer constructor — clean keyword API +function yiemAgent( + ; systemPrompt::String="", + model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + thinkingLevel::thinkingLevel=THINKING_OFF, + tools::Vector{agentTool}=agentTool[], + messages::Vector{agentMessage}=agentMessage[], + formatMsgForLLM::Function=defaultformatMsgForLLM, + preprocessMessages ::Union{Function, Nothing}=nothing, + streamFunction::streamFn=getDefaultStreamFn(), + getApiKey::Union{Function, Nothing}=nothing, + onPayload::Union{Function, Nothing}=nothing, + onResponse::Union{Function, Nothing}=nothing, + beforeToolCall::Union{Function, Nothing}=nothing, + afterToolCall::Union{Function, Nothing}=nothing, + prepareNextTurn::Union{Function, Nothing}=nothing, + prepareNextTurnWithContext::Union{Function, Nothing}=nothing, + sessionId::Union{String, Nothing}=nothing, + thinkingBudgets::Union{Dict{String, Int64}, Nothing}=nothing, + transport::String="auto", + maxRetryDelayMs::Union{Int64, Nothing}=nothing, + toolExecution::toolExecutionMode=EXECUTION_PARALLEL, + ) + new( + agentState(systemPrompt, model, tools, messages), + Set{Tuple{Function, Ref{Bool}}}(), + pendingMessageQueue(QUEUE_ONE_AT_A_TIME), + pendingMessageQueue(QUEUE_ONE_AT_A_TIME), + formatMsgForLLM, + preprocessMessages , + streamFunction, + getApiKey, + onPayload, + onResponse, + beforeToolCall, + afterToolCall, + prepareNextTurn, + prepareNextTurnWithContext, + nothing, + sessionId, + thinkingBudgets, + transport, + maxRetryDelayMs, + toolExecution, + ) end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + end # module type -- 2.52.0 From be42fc4738d65be02a17bcf4ffd5464104a58a30 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 1 Aug 2026 21:46:38 +0700 Subject: [PATCH 004/112] update --- README.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 371084c..4c2844d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,23 @@ -version 0.1.0 -TODO: - [WORKING] build MCTS() for planning - [] executeplan() to execute the plan - -Change from version: 0.0.9 - - \ No newline at end of file +# YiemAgent + +## TODO +- [ ] build prompt() +- [ ] build agent runLoop() +- [ ] build MCP server connector +- [ ] executeplan() to execute the plan +- [ ] add comprehensive tests + +## Changelog + +### Version 0.8.0 +- Converted snake_case fields to camelCase: + - `llmModel`: `base_url` → `baseUrl`, `context_window` → `contextWindow`, `max_tokens` → `maxTokens` +- Converted PascalCase type references to camelCase: + - `AgentState` → `agentState` + - `AgentTool` → `agentTool` + - `AgentMessage` → `agentMessage` + - `PendingMessageQueue` → `pendingMessageQueue` + - `ActiveRun` → `activeRun` + - `StreamFn` → `streamFn` + - `ThinkingLevel` → `thinkingLevel` + - `ToolExecutionMode` → `toolExecutionMode` -- 2.52.0 From 94dd44236dbb7c512601dc748e1cf43040847613 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 1 Aug 2026 21:47:30 +0700 Subject: [PATCH 005/112] update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c2844d..4702dd2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # YiemAgent ## TODO -- [ ] build prompt() +- [WORKING] build prompt() - [ ] build agent runLoop() - [ ] build MCP server connector - [ ] executeplan() to execute the plan -- 2.52.0 From 1cb9ca106b50f4b2b108bb37bfa6aba6a66c80cd Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 1 Aug 2026 23:01:52 +0700 Subject: [PATCH 006/112] update --- src/type.jl | 82 +++++++++++------------------------------------------ 1 file changed, 17 insertions(+), 65 deletions(-) diff --git a/src/type.jl b/src/type.jl index aaa6c76..315b6ce 100644 --- a/src/type.jl +++ b/src/type.jl @@ -86,44 +86,6 @@ struct agentContext # Snapshot of the agent's conversa end - -# ============================================================================ -# Assistant message event types -# ============================================================================ - -abstract type assistantMessageEvent end # Base type for assistant message streaming events - -struct startEvent <: assistantMessageEvent # Message generation started - partial::assistantMessage # The partial message at this point -end -struct textStartEvent <: assistantMessageEvent # Text content block started - contentIndex::Int64 # Index of the content block - partial::assistantMessage # The partial message at this point -end -struct textDeltaEvent <: assistantMessageEvent # Text content block received a chunk - contentIndex::Int64 # Index of the content block - delta::String # New text chunk - partial::assistantMessage # The partial message at this point -end -struct textEndEvent <: assistantMessageEvent # Text content block completed - contentIndex::Int64 # Index of the content block - content::String # Complete text content - partial::assistantMessage # The partial message at this point -end -struct doneEvent <: assistantMessageEvent # Message generation completed successfully - reason::String # Why generation stopped - usage::Usage # Token usage - message::assistantMessage # The completed message -end -struct errorEvent <: assistantMessageEvent # Message generation encountered an error - reason::String # Error reason - errorMessage::Union{String, Nothing} # Human-readable error - usage::Usage # Token usage (partial) - error::assistantMessage # The error message -end - - - # ============================================================================ # Agent state # ============================================================================ @@ -162,7 +124,6 @@ struct toolCall # A tool invocation from the LLM id::String # Unique tool call identifier name::String # Tool name arguments::Dict{String, Any} # Parsed tool arguments - partialJson::Union{String, Nothing} # Raw JSON string during streaming end @@ -207,23 +168,28 @@ end mutable struct yiemAgent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) - conn::NATS.Connection # NATS connection for messaging - followUpQueue::pendingMessageQueue # Messages queued via followUp() when agent would stop + + input_ch::Channel # user sends prompt message to agent. + # if agent is idle, it process user message right away. + # if agent is running, it process user message after + # the current tool call finished. + + followUpQueue::Channel # Messages queued via followUp() during agent is + # running. After the agent loop process all input_ch + # and the agent isn't use tool call. it then process + # followUp message + + output_ch::Channel # agent respond message to user after it process all + # user message in input_ch and all followUp message. formatMsgForLLM::Function # Convert agent messages to LLM message format - preprocessMessages ::Union{Function, Nothing} # Preprocess/transform messages before sending - streamFunction::streamFn # Stream function for streaming responses - getApiKey::Union{Function, Nothing} # Callback to retrieve API key - onPayload::Union{Function, Nothing} # Callback when a payload is sent to the API - onResponse::Union{Function, Nothing} # Callback when a full response is received + preprocessMessages ::Union{Function, Nothing} # Preprocess/transform messages before sending to LLM beforeToolCall::Union{Function, Nothing} # Callback invoked before executing a tool call afterToolCall::Union{Function, Nothing} # Callback invoked after executing a tool call prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context - activeRun::Union{activeRun, Nothing} # Active run state (promise, abort controller) + activeRun::Union{Bool, Nothing} # tracks the currently executing agent run state sessionId::Union{String, Nothing} # Optional session identifier - thinkingBudgets::Union{Dict{String, Int64}, Nothing} # Per-model thinking token budgets - transport::String # Transport mode ("auto" or explicit) maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) toolExecution::toolExecutionMode # Default: run tool calls sequentially or in parallel end @@ -232,44 +198,30 @@ end function yiemAgent( ; systemPrompt::String="", model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), - thinkingLevel::thinkingLevel=THINKING_OFF, tools::Vector{agentTool}=agentTool[], messages::Vector{agentMessage}=agentMessage[], formatMsgForLLM::Function=defaultformatMsgForLLM, preprocessMessages ::Union{Function, Nothing}=nothing, - streamFunction::streamFn=getDefaultStreamFn(), - getApiKey::Union{Function, Nothing}=nothing, - onPayload::Union{Function, Nothing}=nothing, - onResponse::Union{Function, Nothing}=nothing, beforeToolCall::Union{Function, Nothing}=nothing, afterToolCall::Union{Function, Nothing}=nothing, prepareNextTurn::Union{Function, Nothing}=nothing, prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, - thinkingBudgets::Union{Dict{String, Int64}, Nothing}=nothing, - transport::String="auto", maxRetryDelayMs::Union{Int64, Nothing}=nothing, toolExecution::toolExecutionMode=EXECUTION_PARALLEL, ) new( agentState(systemPrompt, model, tools, messages), - Set{Tuple{Function, Ref{Bool}}}(), - pendingMessageQueue(QUEUE_ONE_AT_A_TIME), - pendingMessageQueue(QUEUE_ONE_AT_A_TIME), + Channel(16), formatMsgForLLM, - preprocessMessages , - streamFunction, - getApiKey, + preprocessMessages, onPayload, onResponse, beforeToolCall, afterToolCall, prepareNextTurn, prepareNextTurnWithContext, - nothing, sessionId, - thinkingBudgets, - transport, maxRetryDelayMs, toolExecution, ) -- 2.52.0 From d2081333f652cce41ba030f2ddcb33194a91cbc7 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 2 Aug 2026 17:58:57 +0700 Subject: [PATCH 007/112] update --- src/OLD_interface.jl | 1400 ++++++++++++++++++++++++++++++ src/{type_OLD.jl => OLD_type.jl} | 0 src/interface.jl | 39 +- src/type.jl | 4 +- 4 files changed, 1404 insertions(+), 39 deletions(-) create mode 100644 src/OLD_interface.jl rename src/{type_OLD.jl => OLD_type.jl} (100%) diff --git a/src/OLD_interface.jl b/src/OLD_interface.jl new file mode 100644 index 0000000..8c1ca5c --- /dev/null +++ b/src/OLD_interface.jl @@ -0,0 +1,1400 @@ +module interface + +export addNewMessage, conversation, decisionMaker, reflector, generatechat, + generalconversation, detectWineryName, generateSituationReport + +using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, + DataFrames, Serde +using GeneralUtils +using ..type, ..util, ..llmfunction + +# ------------------------------------------------------------------------------------------------ # +# pythoncall setting # +# ------------------------------------------------------------------------------------------------ # +# Ref: https://github.com/JuliaPy/PythonCall.jl/issues/252 +# by setting the following variables, PythonCall.jl will use: +# 1. system's python and packages installed by system (via apt install) +# or 2. conda python and packages installed by conda +# if these setting are not set (comment out), PythonCall will use its own python and packages that +# installed by CondaPkg.jl (from env_preparation.jl) +# ENV["JULIA_CONDAPKG_BACKEND"] = "Null" # set condapkg backend = none +# systemPython = split(read(`which python`, String), "\n")[1] # system's python path +# 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 --------------------------------------------- # + + +macro executeStringFunction(functionStr, args...) + # Parse the function string into an expression + func_expr = Meta.parse(functionStr) + + # Create a new function with the parsed expression + function_to_call = eval(Expr(:function, + Expr(:call, func_expr, args...), func_expr.args[2:end]...)) + + # Call the newly created function with the provided arguments + function_to_call(args...) +end + + + +""" Think and choose action + +# Arguments + - `config::T1` + config + - `state::T2` + a game state + +# Keyword Arguments + +# Return + - `thoughtdict::Dict` + +# Example +```jldoctest +julia> result = decisionMaker(agent) + +OrderedDict{String, Any} with 4 entries: + "plan" => "The user provided an image of a sparkling white wine (Asolo Prosecco Bella Principessa from Italy) and requested a search for similar wines in the inventory. According to store guidelines, I must st… + "action_name" => "SEARCH_WINE_DATABASE" + "action_input" => "Sparkling white wine from Italy" + "action_result" => "1) winery: Terrazze dell Etna, wine_name: Rose Brut. +``` +""" +function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 + ) where {T<:agent} + @info "YiemAgent decisionMaker() start " @__LINE__ + # lessonDict = copy(JSON.parsefile("lesson.json")) + + # lesson = + # if isempty(lessonDict) + # "" + # else + # lessons = Dict{String, Any}() + # for (k, v) in lessonDict + # lessons[k] = lessonDict[k][:lesson] + # end + + # """ + # You have attempted to help the user before and failed, either because your reasoning for the + # recommendation was incorrect or your response did not exactly match the user expectation. + # The following lesson(s) give a plan to avoid failing to help the user in the same way you + # did previously. Use them to improve your strategy to help the user. + + # Here are some lessons in JSON format: + # $(JSON.json(lessons)) + + # When providing the thought and action for the current trial, that into account these failed + # trajectories and make sure not to repeat the same mistakes and incorrect answers. + # """ + # end + + # recentevents_ind = GeneralUtils.recentElementsIndex( + # length(a.memory["events"]), recentevents; includelatest=true) + + requiredKeys = ["plan", "action_name", "action_input"] + context = + """ + + + """ + + # add context to text of the latest message (in the front). + # use for loop because in openai format, each msg may contain both text and image. + for d in a.chathistory[end]["content"] + if d["type"] == "text" + d["text"] = context * d["text"] + break + end + end + errornote = "N/A" + response = nothing # placeholder for show when error msg show up + + """ + { + "model": "your-model.gguf", + "messages": [ ... ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "agent_action", + "strict": true, + "schema": { + "type": "object", + "properties": { + "think": { + "type": "string", + "description": "Your step-by-step reasoning process. Explain why you are choosing this action." + }, + "action_name": { + "type": "string", + "enum": ["search_web", "get_weather", "calculate_math"], + "description": "The exact name of the tool to execute." + }, + "action_input": { + "type": "object", + "properties": { + "query": { "type": ["string", "null"], "description": "For search_web" }, + "location": { "type": ["string", "null"], "description": "For get_weather" }, + "equation": { "type": ["string", "null"], "description": "For calculate_math" } + }, + "required": ["query", "location", "equation"], + "additionalProperties": false + } + }, + "required": ["think", "action_name", "action_input"], + "additionalProperties": false + } + } + } + } + """ + + # strict output format + response_format = Dict( + "type"=> "json_schema", + "json_schema"=> Dict( + "name"=> "user_profile", + "strict"=> true, + "schema"=> Dict( + "type"=> "object", + "properties"=> Dict( + "plan"=> Dict("type"=> "string"), + "action_name"=> Dict("type"=> "string"), + "action_input"=> Dict("type"=> "string"), + ), + "required"=> ["plan", "action_name", "action_input"], + "additionalProperties"=> false + ) + ) + ) + + msg = Dict( + "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", + "messages"=> a.chathistory, + "temperature"=> 0.7, + "response_format"=> response_format, + ) + + for attempt in 1:maxattempt + response = a.context.text2textInstructLLM(a.id, msg) + response = GeneralUtils.remove_french_accents(response) + # think, response = GeneralUtils.extractthink(response) + + # dollar sign in Julia means string interpolation + while occursin('$', response) + response = replace(response, '$' => "USD") + end + + # responsedict = nothing + # try + # responsedict = Serde.parse_yaml(response) + # catch e + # println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + # continue + # end + + # # check whether all answer's key points are in responsedict + # println("\n---") + # println(responsedict) + # println("---\n") + # ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) + + # if !ispass + # errornote = errormsg + # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)-> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + # continue + # end + + responsedict = JSON.parse(response) + + if responsedict["action_input"] == "CHAT_BOX" && + occursin("similar", responsedict["action_input"]) + + continue + end + + # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] + # errornote = "Your previous attempt didn't use the given functions" + # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") + # continue + # end + + # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + # pprintln(responsedict) + @info "YiemAgent decisionMaker() end " @__LINE__ + return responsedict + end + + # in case decisionMaker failed, force to use generatechat!() + responsedict = OrderedDict( + "plan"=> "N/A", + "action_name"=> "CHAT_BOX", + "action_input"=> "N/A" + ) + return responsedict +end + + + +""" Assigns a scalar value to each new child node to be used for selec- +tion and backpropagation. This value effectively quantifies the agent's progress in task completion, +serving as a heuristic to steer the search algorithm towards the most promising regions of the tree. + +# Arguments + - `state<:AbstractDict` + one of Yiem's agent + - `text2textInstructLLM::Function` + A function that handles communication to LLM service + +# Return + - `score::Integer` + +# Example +```jldoctest +julia> +``` + +# Signature +""" +function evaluator(a::T1, timeline, decisiondict, evaluateecontext + ) where {T1<:agent} + + systemmsg = + """ + + - You are a master sommelier of an online wine store. + + + - Under your supervision, a trainee sommelier is engaging with a store customer. Each time the customer speaks, the trainee will assess the situation, determine the next course of action, and pause to await your guidance before proceeding. + + + - Improve a trainee sommelier decision based on the store policy and guidelines while ensuring seamless interactions between the trainee and customers. + + + - trajectory: A conversation between your trainee and the customer that have occurred up until now + - evaluatee_context: The context that evaluatee use to make a decision + - evaluatee_decision: The decision made by the evaluatee, consists of the following elements: + "plan" is the trainee's plan + "action_name" is the name of the action taken, which can be one of the available tool name. + "action_input" is the input to the action. + + + - Use only infomation provided by the store policy and guidelines as a bedrocks for your response. + + + - The trainee's plan, action_name, and action_input must be logically consistent + - The trainee's action_input should be in a proper format as specified by the tools. + - The trainee's action name and action input should make sense. For example, if the trainee isn't finished talking, he shouldn't use the END_CONVER_GUIDELINE tool. + + + 1) trajectory_evaluation: Analyze the trajectory of a solution to answer the user's original question. + - Evaluate the correctness of each section and the overall trajectory based on the given question. + - Provide detailed reasoning and analysis, focusing on the latest thought, action, and observation. + - Incomplete trajectory are acceptable if the thoughts and actions up to that point are correct, even if the final answer isn't reached. + - Do not generate additional thoughts or actions. + 2) decision_evaluation: + - Examine how the trainee's decisions align with the store's policies and guidelines before proceeding. + 3) suggestion: Based store policy and guidelines, provide a suggestion for the immediate decision step only. + 4) approval: Can be "yes" or "no". "no" if the suggestion contradict the trainee's decision; otherwise, it is "yes". + + + + { + "trajectory_evaluation": "...", + "decision_evaluation": "...", + "suggestion": "...", + "approval": "...", + } + + + Let's begin! + """ + requiredKeys = [:trajectory_evaluation, :decision_evaluation, :approval, :suggestion] + errornote = "N/A" + + for attempt in 1:10 + evaluateecontext = replace(evaluateecontext, "" => "") + evaluateecontext = replace(evaluateecontext, "" => "") + + context = + """ + + + $timeline + + + $evaluateecontext + + + {plan: $(decisiondict["plan"]), action_name: $(decisiondict["action_name"]), action_input: $(decisiondict["action_input"])} + + P.S. $errornote + + """ + + unformatPrompt = + [ + Dict("name" => "system", "text" => systemmsg), + ] + + # put in model format + prompt = GeneralUtils.formatLLMtext(unformatPrompt, a.llmFormatName) + # add info + prompt = prompt * context + + response = a.context.text2textInstructLLM(prompt; senderId=a.id) + response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) + response = GeneralUtils.remove_french_accents(response) + # response = replace(response, '$'=>"USD") + think, response = GeneralUtils.extractthink(response) + + responsedict = nothing + try + responsedict = copy(JSON.parsefile(response)) + catch + println("\nERROR YiemAgent generatechat() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + + # check whether all answer's key points are in responsedict + ispass, errormsg = checkAgentResponse_JSON(responsedict, requiredKeys) + if !ispass + errornote = errormsg + println("\nERROR YiemAgent evaluator() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + continue + end + + # if accepted_as_answer ∉ ["yes", "no"] # [PENDING] add errornote into the prompt + # error("generated accepted_as_answer has wrong format") + # end + + println("\nEvaluator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + pprintln(Dict(responsedict)) + return responsedict + end + error("Evaluator failed to generate an evaluation, Response: \n$response\n<|End of error|>") +end + +""" Chat with llm. + +# Example userinput + +image_path = "test/large_image.png" +image_bytes = read(image_path) +base64_string = base64encode(image_bytes) + +# 2. Match the MIME type according to your file extension (e.g., png, jpeg) +mime_type = "image/png" +data1_uri = "data:;base64," + +# 3. Construct payload with the Data URI +message => Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "Describe this image for me"), + Dict( + "type" => "image_url", + "image_url" => Dict("url" => data_uri) + ) + ] + ) + +""" +function conversation(a::sommelier; userinput::Union{Dict{String, Any}, JSON.Object{String, Any}}, + maximumMsg=50, max_think_loop::Integer=3) + + @info "YiemAgent conversation() start " @__LINE__ + userinput = GeneralUtils.dictify(userinput; keytype=String, sort_order=["text"]) + + # find text in usermsg + usertext = nothing + for (i, d) in enumerate(userinput["content"]) + if d["type"] == "text" + d["text"] = GeneralUtils.remove_french_accents(d["text"]) + usertext = d["text"] + end + end + + if usertext == "newtopic" + clearhistory(a) + return "Okay. What shall we talk about?" + else + + # add usermsg to a.chathistory but how do I handle images? + addNewMessage(a, "user", userinput; maximumMsg=maximumMsg) + + # thinking loop until AI wants to communicate with the user + loopcount = 0 + while true + loopcount += 1 + if loopcount > max_think_loop + + thoughtdict, result_raw = generatechat!(a) + assistant_response = Dict{String, Any}( + "role" => "assistant", + "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] + ) + addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) + + items_info = [] + send_item_ind = [] # index of the item being send to frontend + if haskey(a.memory["shortmem"], "items_info") + for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) + @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ + if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) + push!(items_info, deepcopy(item)) + push!(send_item_ind, i) + @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ + end + end + # remove sent items + deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) + @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ + end + + response_to_frontend = Dict{String, Any}( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => thoughtdict["action_input"]), + Dict( + "type" => "items_info", + "items_info" => items_info + ), + ] + ) + + return response_to_frontend + end + + + thoughtdict, result_raw = think(a) + + if thoughtdict["action_name"] ∈ ["CHAT_BOX"] + assistant_response = Dict{String, Any}( + "role" => "assistant", + "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] + ) + addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) + + items_info = [] + send_item_ind = [] # index of the item being send to frontend + if haskey(a.memory["shortmem"], "items_info") + for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) + @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ + if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) + push!(items_info, deepcopy(item)) + push!(send_item_ind, i) + @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ + end + end + # remove sent items + deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) + @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ + end + + response_to_frontend = Dict{String, Any}( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => thoughtdict["action_input"]), + Dict( + "type" => "items_info", + "items_info" => items_info + ), + ] + ) + + """ intended message to send to frontend should have the following format. + response_to_frontend = Dict{String, Any}( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => "assistant_text_response"), + Dict( + "type" => "items_info", + "items_info" => [ + Dict( + "wine_name"=> "wine name 1", + "wine_id"=> "...", + "image"=> base64 encoded image, + ... + ), + Dict( + "wine_name"=> "wine name 2", + "wine_id"=> "...", + "image"=> base64 encoded image, + ... + ), + ] + ), + ] + ) + """ + + + return response_to_frontend + else # still in action + + action_name = thoughtdict["action_name"] + action_input = thoughtdict["action_input"] + + action_call = Dict{String, Any}( + "role" => "action_call", + "content" => [Dict("type" => "text", "text" => "{action_name: $action_name, action_input: $action_input}"),] + ) + + addNewMessage(a, "action_call", action_call; maximumMsg=maximumMsg) + + action_result = thoughtdict["action_result"] + actionresult = Dict{String, Any}( + "role" => "action_result", + "content" => [Dict("type" => "text", "text" => "$action_result"),] + ) + + addNewMessage(a, "actionresult", actionresult; maximumMsg=maximumMsg) + @info "YiemAgent conversation() end think count $loopcount " @__LINE__ + end + end + end +end + + +""" +# Arguments + +# Return + +# Example +```jldoctest +julia> +``` + +""" +function think(a::T)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} + # a.memory[:recap] = generateSituationReport(a, a.context["text"2textInstructLLM]; skiprecent=0) + @info "YiemAgent think() start " @__LINE__ + thoughtdict = decisionMaker(a) + @info "YiemAgent think() 1 " @__LINE__ + @show thoughtdict + println("---\n") + + result_raw = nothing + if thoughtdict["action_name"] ∈ ["CHAT_BOX"] + + # sometime CHAT_BOX input is too short. + # if thoughtdict["action_input] < 20 character, use generatechat!() + if length(thoughtdict["action_input"]) < 20 + thoughtdict, result_raw = generatechat!(a) + else + thoughtdict["action_result"] = "Action result is the next user dialogue." + result_raw = thoughtdict["action_input"] + end + + elseif thoughtdict["action_name"] == "END_CONVER_GUIDELINE" + + thoughtdict, result_raw = end_conversation_guideline!(a, thoughtdict) + + elseif thoughtdict["action_name"] ∈ ["WINE_PRESENTATION_GUIDELINE"] + + thoughtdict, result_raw = wine_presentation_guideline!(a, thoughtdict) + + elseif thoughtdict["action_name"] == "SEARCH_WINE_DATABASE" + + thoughtdict, result_raw = search_wine_database!(a, thoughtdict; useSQLLLM=false) + if result_raw !== nothing && result_raw isa Vector + if haskey(a.memory["shortmem"], "items_info") + append!(a.memory["shortmem"]["items_info"], result_raw) + else + a.memory["shortmem"]["items_info"] = result_raw + end + end + + else + + error("condition is not defined ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + end + + @info "YiemAgent think() end " @__LINE__ + # @show thoughtdict + println("---\n") + return (thoughtdict=thoughtdict, result_raw=result_raw) +end + +function chatbox!(a::T, thoughtdict::AbstractDict + )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} + thoughtdict["action_result"] = "Action result is the next user dialogue." + return (thoughtdict=thoughtdict, result_raw=nothing) +end + +function end_conversation_guideline!(a::T, thoughtdict::AbstractDict + )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} + + guideline = + """ + + - Provide customer with store contact info and business hours + - Invite customer to comeback + + Business Hours: everyday 9.00-20.00 + Tel. 0863055790 + + + """ + thoughtdict["action_result"] = guideline + + return (thoughtdict=thoughtdict, result_raw=nothing) +end + +function wine_presentation_guideline!(a::T, thoughtdict::AbstractDict + )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} + + guideline = + """ + + - Provide detailed introductions of the wines you've found to the user. + - Explain how the wine could match the user's intention and what its effects might mean for the user's experience. + - If multiple wines are available, highlight their differences and provide a comprehensive comparison of how each option aligns with the user's intention and what the potential effects of each option could mean for the user's experience. + - Provide your personal recommendation and provide a brief explanation of why you recommend it. + - People don't describe wine quality level in numbers so use convertion_table if neccessary + + Intensity level: + 1 to 2: May correspond to "light-bodied" or a similar description. + 2 to 3: May correspond to "med light bodied", "medium light" or a similar description. + 3 to 4: May correspond to "medium bodied" or a similar description. + 4 to 5: May correspond to "med full bodied", "medium full" or a similar description. + 4 to 5: May correspond to "full bodied" or a similar description. + Sweetness level: + 1 to 2: May correspond to "dry", "no sweet" or a similar description. + 2 to 3: May correspond to "off dry", "less sweet" or a similar description. + 3 to 4: May correspond to "semi sweet" or a similar description. + 4 to 5: May correspond to "sweet" or a similar description. + 4 to 5: May correspond to "very sweet" or a similar description. + Tannin level: + 1 to 2: May correspond to "low tannin" or a similar description. + 2 to 3: May correspond to "semi low tannin" or a similar description. + 3 to 4: May correspond to "medium tannin" or a similar description. + 4 to 5: May correspond to "semi high tannin" or a similar description. + 4 to 5: May correspond to "high tannin" or a similar description. + Acidity level: + 1 to 2: May correspond to "low acidity" or a similar description. + 2 to 3: May correspond to "semi low acidity" or a similar description. + 3 to 4: May correspond to "medium acidity" or a similar description. + 4 to 5: May correspond to "semi high acidity" or a similar description. + 4 to 5: May correspond to "high acidity" or a similar description. + + + """ + thoughtdict["action_result"] = guideline + + return (thoughtdict=thoughtdict, result_raw=nothing) +end + + +#PENDING +function generatechat!(a::T; maxattempt::Integer=10 + )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} + @info "YiemAgent generatechat!() start " @__LINE__ + # lessonDict = copy(JSON.parsefile("lesson.json")) + + # lesson = + # if isempty(lessonDict) + # "" + # else + # lessons = Dict{String, Any}() + # for (k, v) in lessonDict + # lessons[k] = lessonDict[k][:lesson] + # end + + # """ + # You have attempted to help the user before and failed, either because your reasoning for the + # recommendation was incorrect or your response did not exactly match the user expectation. + # The following lesson(s) give a plan to avoid failing to help the user in the same way you + # did previously. Use them to improve your strategy to help the user. + + # Here are some lessons in JSON format: + # $(JSON.json(lessons)) + + # When providing the thought and action for the current trial, that into account these failed + # trajectories and make sure not to repeat the same mistakes and incorrect answers. + # """ + # end + + # recentevents_ind = GeneralUtils.recentElementsIndex( + # length(a.memory["events"]), recentevents; includelatest=true) + + systemmsg = + """ + # store_policy + - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. + - If you found wines in the store's database, they are in stock. + - You can only recommend wines that are currently in our inventory + - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. + - Ask the user one question at a time. + - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. + - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. + - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. + - Spicy foods should be paired only with light red wines. + - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user immediately if they are looking for these types of wines. Do not sell our wines as such. + - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. + + # store_guidelines + - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. + - Customer may provide images for you to look up. + - Encourage the customer to explore different options and try new things. + - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. + - Your store carries only wine. + - Vintage 0 means non-vintage. + - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. + + # situation + You are continuing the conversation with the user. + + # your role + Your name is $(a.name). You are a helpful sommelier for website-based $(a.retailername)'s wine store. You are working under your mentor supervision. + + # objective + - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. + - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. + + # your responsibility includes + - According to the store's policy and guidelines, continuing conversation with the customer using CHAT_BOX action. + - Keep the conversation with the customer going smoothly + + # your responsibility does NOT includes + - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. + - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. + - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. + + # you should then respond to the user with interleaving plan, action_name, action_input in JSON format + 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. + 2) "action_name", action_name must be CHAT_BOX. + 3) "action_input", Dialogue you want to chat with the user according to your plan. + After the action is executed you gets "action_result". It is the output from the action you selected. + + # available actions + "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. + """ + + system_msg = Dict( + "role" => "system", + "content" => [ + Dict("type" => "text", "text" => systemmsg), + ] + ) + + chathistory = deepcopy(a.chathistory[2:end]) # use deep copy because I want to replace system msg + pushfirst!(chathistory, system_msg) + + requiredKeys = ["plan", "action_name", "action_input"] + + errornote = "N/A" + response = nothing # placeholder for show when error msg show up + + for attempt in 1:maxattempt + if attempt > 1 + println("\nYiemAgent generatechat() attempt $attempt/$maxattempt ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + end + + response_format = Dict( + "type"=> "json_schema", + "json_schema"=> Dict( + "name"=> "user_profile", + "strict"=> true, + "schema"=> Dict( + "type"=> "object", + "properties"=> Dict( + "plan"=> Dict( + "type"=> "string", + "description" => "Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.", + ), + "action_name"=> Dict( + "type"=> "string", + "description" => "action_name must be CHAT_BOX", + ), + "action_input"=> Dict( + "type"=> "string", + "description" => "Dialogue you want to chat with the user according to your plan.", + ), + ), + "required"=> ["plan", "action_name", "action_input"], + "additionalProperties"=> false + ) + ) + ) + + msg = Dict( + "model" => "gemma-4-E4B-it-UD-Q4_K_XL", + "messages" => chathistory, + "temperature" => 0.7, + "response_format"=> response_format, + ) + + response = a.context.text2textInstructLLM(a.id, msg) + response = GeneralUtils.clean_json_response(response) + response = GeneralUtils.remove_french_accents(response) + think, response = GeneralUtils.extractthink(response) + + response = strip(response) + + responsedict = nothing + if occursin(requiredKeys[2], response) + try + _responsedict = JSON.parse(response) + responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) + catch + println("\nERROR YiemAgent generatechat() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + else + println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + continue + end + + + # check whether all answer's key points are in responsedict + ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) + if !ispass + errornote = errormsg + println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + continue + end + + # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] + # errornote = "Your previous attempt didn't use the given functions" + # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") + # continue + # end + + # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + # pprintln(responsedict) + responsedict["action_result"] = "Action result is the next user dialogue." + @info "YiemAgent generatechat!() end " @__LINE__ + return (thoughtdict=responsedict, result_raw=responsedict["action_input"]) + end + @info "YiemAgent generatechat() failed to generate a thought " @__LINE__ + error("YiemAgent generatechat() failed to generate a thought ", response) +end + + +function generatequestion(a, text2textInstructLLM::Function, timeline)::String + systemmsg = + """ + Your role: + Your name is $(a.name). You are a helpful English-speaking, website-based sommelier for $(a.retailername)'s online store currently talking with the user. + Your goal includes: + 1) Help the user select the best wines from your inventory that align with the user's preferences + 2) Thanks the user when they don't need any further assistance and invite them to comeback next time + + Your responsibility includes: + 1) From your point of view as a sommelier helping the user, ask yourself multiple questions based on the current situation + + Your responsibility does NOT includes: + 1) Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. + 2) Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. + 3) Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. + + At each round of conversation, you will be given the info: + Additional info: ... + Your recent events: latest 5 events of the situation + + You must follow the following guidelines: + - Your question should be specific, self-contained and not require any additional context. + - Once the user has chose their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. + + You should follow the following guidelines: + - Focus on the latest conversation + - If the user interrupts, prioritize the user + - If you don't already know, find out the user's budget + - If you don't already know, find out the type of wine the user is looking for, such as red, white, sparkling, rose, dessert, fortified + - If you don't already know, find out the occasion for which the user is buying wine + - If you don't already know, find out the characteristics of wine the user is looking for, such as tannin, sweetness, intensity, acidity + - If you don't already know, find out what food will be served with wine + - If you haven't already, introduce the wines you found in the database to the user first + - Generally speaking, your inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. + - All wines in your inventory are always in stock. + - Engage in conversation to indirectly investigate the customer's intention, budget and preferences before checking your inventory. + - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. + - Once the user has selected their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. + - Medium and full-bodied red wines are bad with spicy foods. + - If a customer requests information about discounts, quantity, rewards programs, promotions, delivery options, boxes, gift wrapping, packaging, or personalized messages, please inform them that they can contact our sales team at the store. + + You should then respond to the user with: + 1) Thought: State your thought about the current situation + 2) Q: "Ask yourself" at least three, but no more than five, questions about the situation from your perspective. + 3) A: Given the situation, "answer to yourself" the best you can. Do not generate any extra text after you finish answering all questions + + You must only respond in format as described below: + Q1: ... + A1: ... + Q2: ... + A2: ... + ... + + Here are some examples: + Q: What the user is looking for? + A: The user is asking for a MPV car with 7-seat + Q: What do I know? + A: The user is looking for a car with 7-seat. Our dealer sell these kind of cars + Q: What brands the user prefer? + A: I don't know. The user didn't mentioned that. Let's find out. + Q: What else do I need to know before proceeding? + A: I don't know about the user budget, car's color, and other user's preferences yet. Let's find out more about the user's preferences. + Q: I'm still lacking information regarding the user's preferences for the powertrain. I've asked the user twice already, but perhaps they're not familiar with this. What should I do. + A: I'll proceed without asking the user about the powertrain. + Q: The user is buying for her husband, should I dig in to get more information? + A: Yes, I should. So that I have better idea about the user's preferences. + Q: Why the user saying this? + A: The user does not want an SUV because it does not have sliding doors + Q: The user is asking for a cappuccino. Do I have it at my cafe? + A: No I don't have. + Q: Since I don't have a cappuccino but I have a Late, should I ask if they are okay with that? + A: Yes, I should. + Q: Are they allergic to milk? + A: Since they mentioned a cappuccino before, it seems they are not allergic to milk. + Q: Have I checked the inventory yet? + A: No. I need more information from the user including ... + Q: What else do I need to know? + A: ... + Q: Should I present my item to the user? + A: Not yet, I will need to check my inventory first. + Q: Should I check our inventory now? + A: ... + Q: What the user intend to do with the car? + A: I don't know yet. Let's ask the user. + Q: What do I have in our inventory? + A: ... + Q: Which items are within the user price range? And which items are out of the user price rance? + A: ... + Q: Do I have what the user is looking for in our stock? + A: ... + Q: Am I certain about the information I'm going to share with the user, or should I verify the information first? + A: ... + Q: What should I do? + A: ... + Q: What shouldn't I do? + A: ... + Q: what kind of car suitable for off-road trip? + A: A four-wheel drive SUV is a good choice for off-road trips. + Q: What car specification would satisfy the user's needs? + A: The user is seeking an eco-friendly vehicle that accommodates seven passengers, including seniors and children, with prioritized accessibility and efficient refueling. While electric vehicles (EVs) offer eco-friendly benefits, their long charging times make hybrid models more practical for fast refueling. Additionally, a lower ground level is essential for ease of entry/exit for seniors and children. A hybrid multi-purpose vehicle (MPV) emerges as the optimal solution, balancing sustainability, seating capacity, accessibility, and refueling efficiency. + + Let's begin! + """ + + header = ["Q1:"] + dictkey = ["q1"] + + # context = + # if length(a.memory["shortmem"]["available_wine"]) != 0 + # "Available wines you've found in your inventory so far: $(availableWineToText(a.memory["shortmem"]["available_wine"]))" + # else + # "N/A" + # end + database_search_result = a.memory["shortmem"]["db_search_result"] + + # recent_ind = GeneralUtils.recentElementsIndex(length(a.memory[:events]), recent) + # recentevents = a.memory[:events][recent_ind] + # timeline = createTimeline(recentevents; eventindex=recent_ind) + errornote = "N/A" + response = nothing # store for show when error msg show up + + # recap = + # if length(a.memory[:recap]) <= recent + # "N/A" + # else + # recapkeys = keys(a.memory[:recap]) + # recapkeys_vec = [i for i in recapkeys] + # recapkeys_vec = recapkeys_vec[1:end-recent] + # tempmem = OrderedDict() + # for (k, v) in a.memory[:recap] + # if k ∈ recapkeys_vec + # tempmem[k] = v + # end + # end + + # GeneralUtils.dictToString(tempmem) + # end + + llmkwargs=Dict( + :num_ctx => 32768, + :temperature => 0.5, + ) + + for attempt in 1:10 + if attempt > 1 + println("\nYiemAgent generatequestion() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + end + + usermsg = + """ + Additional info: $database_search_result + Your recent events: $timeline + P.S. $errornote + """ + + _prompt = + [ + Dict("name" => "system", "text" => systemmsg), + Dict("name" => "user", "text" => usermsg) + ] + + # put in model format + prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) + + response = text2textInstructLLM(prompt; + modelsize="medium", llmkwargs=llmkwargs, senderId=a.id) + response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) + think, response = GeneralUtils.extractthink(response) + + # make sure generatequestion() don't have wine name that is not from retailer inventory + # check whether an agent recommend wines before checking inventory or recommend wines + # outside its inventory + # ask LLM whether there are any winery mentioned in the response + mentioned_winery = detectWineryName(a, response) + if mentioned_winery != "None" + mentioned_winery = String.(strip.(split(mentioned_winery, ","))) + + # check whether the wine is in event + isWineInEvent = false + for winename in mentioned_winery + for event in a.memory["events"] + if event["observation"] !== nothing && occursin(winename, event["observation"]) + isWineInEvent = true + break + end + end + end + + # if wine is mentioned but not in timeline or shortmem, + # then the agent is not supposed to recommend the wine + if isWineInEvent == false + errornote = "Your previous attempt mentioned wines that are not in your inventory which is not allowed." + println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + end + + q_number = count("Q", response) + + # check for valid response + if q_number < 1 + errornote = "Your previous attempt has too few questions." + println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + # check whether "A1" is in the response, if not error. + elseif !occursin("A1:", response) + errornote = "Your previous attempt does not have A1:" + println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + + # check whether response has all header + detected_kw = GeneralUtils.detectKeywordVariation(header, response) + if 0 ∈ values(detected_kw) + errornote = "\nYour previous attempt did not have all points according to the required response format" + println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + elseif sum(values(detected_kw)) > length(header) + errornote = "\nYour previous attempt has duplicated points according to the required response format" + println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + + responsedict = GeneralUtils.textToDict(response, header; + dictKey=dictkey, symbolkey=true) + response = "Q1: " * responsedict["q1"] + println("\nYiemAgent generatequestion() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + try pprintln(response) catch e println(response) end + + return response + end + error("YiemAgent generatequestion() failed to generate a response ", response) +end + + +function generateSituationReport(a, text2textInstructLLM::Function; skiprecent::Integer=0 + )::OrderedDict + + systemmsg = + """ + You are an assistant being in the given events. + Your task is to writes a summary for each event seperately into an ongoing, interleaving series. + + At each round of conversation, you will be given the situation: + Total events: number of events you need to summarize. + Events timeline: ... + Context: ... + + You should follow the following guidelines: + - Use the word "user" and "assistant" instead of their name in the report + + You should then respond to the user with the following: + Event: a detailed summary for each event without exaggerated details. + + You must only respond in format as described below: + Event_1: ... + Event_2: ... + ... + + Here are some examples: + Event_1: The user ask me about where to buy a toy. + Event_2: I told the user to go to the store at 2nd floor. + + Event_1: The user greets the assistant by saying 'hello'. + Event_2: The assistant respond warmly and inquire about how he can assist the user. + + Let's begin! + """ + + header = ["Event_$i:" for i in eachindex(a.memory["events"])] + dictkey = lowercase.(["Event_$i" for i in eachindex(a.memory["events"])]) + + ind = GeneralUtils.nonRecentElementsIndex(length(a.memory["events"]), skiprecent) + events = a.memory["events"][ind] + timeline = createTimeline(events) + + errornote = "N/A" + response = nothing # store for show when error msg show up + for attempt in 1:10 + if attempt > 1 # use to prevent LLM generate the same respond over and over + println("\nYiemAgent generateSituationReport() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + end + + usermsg = """ + Total events: $(length(events)) + Events timeline: $timeline + P.S. $errornote + """ + + _prompt = + [ + Dict("name" => "system", "text" => systemmsg), + Dict("name" => "user", "text" => usermsg) + ] + + # put in model format + prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3") + + response = text2textInstructLLM(prompt; senderId=a.id) + response = GeneralUtils.deFormatLLMtext(response, "qwen3") + + # check whether response has all header + detected_kw = GeneralUtils.detectKeywordVariation(header, response) + kwvalue = [i for i in values(detected_kw)] + zeroind = findall(x -> x == 0, kwvalue) + missingkeys = [header[i] for i in zeroind] + if 0 ∈ values(detected_kw) + errornote = "$missingkeys are missing in your previous attempt" + println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + elseif sum(values(detected_kw)) > length(header) + errornote = "Your previous response has duplicated events" + println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + + responsedict = GeneralUtils.textToDict(response, header; + dictKey=dictkey, symbolkey=true) + + println("\ngenerateSituationReport() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + try pprintln(response) catch e println(response) end + + return responsedict + end + error("generateSituationReport failed to generate a response ", response) +end + + +function detectWineryName(a, text) + systemmsg = + """ + You are a sommelier of a wine store. + Your task is to identify and list any winery names mentioned in the provided text. + + At each round of conversation, you will be given the situation: + Text: a text describing the situation. + + Tips: + - Winery usually contains Château, Chateau, Domaine, Côte, Cotes, St. de, or a combination of these words. + + You should then respond to the user with: + Winery_names: A list of winery names mentioned in the text or "None" if no winery name is mentioned. + + You must only respond in format as described below: + Winery_names: ... + + Here are some examples: + Winery_names: Domaine Courbis, Chateau Lafite Rothschild, Matarromera Domaine Roulot, Château, Cotes + + Let's begin! + """ + + header = ["Winery_names:"] + dictkey = ["winery_names"] + + response = nothing # placeholder for show when error msg show up + + for attempt in 1:10 + usermsg = """ + Text: $text + """ + _prompt = + [ + Dict("name" => "system", "text" => systemmsg), + Dict("name" => "user", "text" => usermsg) + ] + + # put in model format + prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) + + response = a.context.text2textInstructLLM(prompt; senderId=a.id) + response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) + think, response = GeneralUtils.extractthink(response) + println("\ndetectWineryName() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + try pprintln(response) catch e println(response) end + + # check whether response has all header + detected_kw = GeneralUtils.detectKeywordVariation(header, response) + missingkeys = [k for (k, v) in detected_kw if v === nothing] + + if !isempty(missingkeys) + errornote = "$missingkeys are missing from your previous response" + println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + elseif sum([length(i) for i in values(detected_kw)]) > length(header) + errornote = "\nYour previous attempt has duplicated points according to the required response format" + println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") + continue + end + + responsedict = GeneralUtils.textToDict(response, header; + dictKey=dictkey, symbolkey=true) + + result = responsedict["winery_names"] + + return result + end + error("detectWineryName failed to generate a response") + end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +end # module interface + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/type_OLD.jl b/src/OLD_type.jl similarity index 100% rename from src/type_OLD.jl rename to src/OLD_type.jl diff --git a/src/interface.jl b/src/interface.jl index 8c1ca5c..69a3b1b 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -1,52 +1,15 @@ module interface -export addNewMessage, conversation, decisionMaker, reflector, generatechat, - generalconversation, detectWineryName, generateSituationReport +export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Serde using GeneralUtils using ..type, ..util, ..llmfunction -# ------------------------------------------------------------------------------------------------ # -# pythoncall setting # -# ------------------------------------------------------------------------------------------------ # -# Ref: https://github.com/JuliaPy/PythonCall.jl/issues/252 -# by setting the following variables, PythonCall.jl will use: -# 1. system's python and packages installed by system (via apt install) -# or 2. conda python and packages installed by conda -# if these setting are not set (comment out), PythonCall will use its own python and packages that -# installed by CondaPkg.jl (from env_preparation.jl) -# ENV["JULIA_CONDAPKG_BACKEND"] = "Null" # set condapkg backend = none -# systemPython = split(read(`which python`, String), "\n")[1] # system's python path -# 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 --------------------------------------------- # -macro executeStringFunction(functionStr, args...) - # Parse the function string into an expression - func_expr = Meta.parse(functionStr) - - # Create a new function with the parsed expression - function_to_call = eval(Expr(:function, - Expr(:call, func_expr, args...), func_expr.args[2:end]...)) - - # Call the newly created function with the provided arguments - function_to_call(args...) -end - """ Think and choose action diff --git a/src/type.jl b/src/type.jl index 315b6ce..ef15777 100644 --- a/src/type.jl +++ b/src/type.jl @@ -166,7 +166,9 @@ end # Agent struct # ============================================================================ -mutable struct yiemAgent # High-level agent wrapper +abstract type agent end + +mutable struct yiemAgent <: agent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) input_ch::Channel # user sends prompt message to agent. -- 2.52.0 From 1ab97b3972537bac1f2482f74b85e0ababeb8692 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 2 Aug 2026 20:57:53 +0700 Subject: [PATCH 008/112] add yiemAgent --- etc.jl | 38 ++++++++---- src/type.jl | 167 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 180 insertions(+), 25 deletions(-) diff --git a/etc.jl b/etc.jl index a35aae6..5d9b549 100644 --- a/etc.jl +++ b/etc.jl @@ -1,10 +1,28 @@ -# check if this column has vector embedding. if there is one, seach vector version instead - column_name_embedding = column_name * "_embedding" - if occursin(column_name_embedding, tables_schema[column_name_embedding]) - vector_column = Dict( - "table_name"=> table_name, - "column_name"=> column_name_embedding, - "operator"=> "vector_similarity", - "value"=> column_obj["value"] - ) - end \ No newline at end of file + + + +using Base.Threads + +println("Active Julia threads: ", nthreads()) + +# A CPU-heavy helper function +function compute_work(id, iterations) + println(" [Start] Task $id on Thread #", threadid()) + + total = 0.0 + for i in 1:iterations + total += sin(i) * cos(i) + end + + println(" [Done] Task $id on Thread #", threadid()) + return total +end + +# ==================================================================== +# 1. Basic @spawn and fetch +# ==================================================================== +println("\n--- 1. Single Task Spawning ---") + +# Threads.@spawn creates a Task and schedules it onto an available worker thread +task1 = Threads.@spawn compute_work("A", 10_000_000) +println(typeof(task1)) \ No newline at end of file diff --git a/src/type.jl b/src/type.jl index ef15777..c206fe4 100644 --- a/src/type.jl +++ b/src/type.jl @@ -1,5 +1,6 @@ module type - export agent, sommelier, companion, virtualcustomer, agentContext + export agent, sommelier, companion, virtualcustomer, agentContext, yiemAgent, + run_agent, take_response, follow_up, stop_agent using Dates, UUIDs, DataStructures, JSON, NATS @@ -168,6 +169,9 @@ end abstract type agent end +""" +docstring +""" mutable struct yiemAgent <: agent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) @@ -176,13 +180,15 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # if agent is running, it process user message after # the current tool call finished. - followUpQueue::Channel # Messages queued via followUp() during agent is + followUpQueue::Channel # Messages queued via follow_up() during agent is # running. After the agent loop process all input_ch - # and the agent isn't use tool call. it then process - # followUp message + # and the agent isn't using tool call, it processes + # followUp messages - output_ch::Channel # agent respond message to user after it process all - # user message in input_ch and all followUp message. + output_ch::Channel # agent sends response message to user after processing + # all user messages in input_ch and all followUp messages. + + _task::Union{Task, Nothing} # Background task running the agent loop formatMsgForLLM::Function # Convert agent messages to LLM message format preprocessMessages ::Union{Function, Nothing} # Preprocess/transform messages before sending to LLM @@ -190,35 +196,43 @@ mutable struct yiemAgent <: agent # High-level agent wrapper afterToolCall::Union{Function, Nothing} # Callback invoked after executing a tool call prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context - activeRun::Union{Bool, Nothing} # tracks the currently executing agent run state sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) toolExecution::toolExecutionMode # Default: run tool calls sequentially or in parallel end -# Outer constructor — clean keyword API +""" +docstring +""" function yiemAgent( ; systemPrompt::String="", - model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + model=nothing, tools::Vector{agentTool}=agentTool[], messages::Vector{agentMessage}=agentMessage[], formatMsgForLLM::Function=defaultformatMsgForLLM, - preprocessMessages ::Union{Function, Nothing}=nothing, + preprocessMessages::Union{Function, Nothing}=nothing, beforeToolCall::Union{Function, Nothing}=nothing, afterToolCall::Union{Function, Nothing}=nothing, prepareNextTurn::Union{Function, Nothing}=nothing, prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, - toolExecution::toolExecutionMode=EXECUTION_PARALLEL, + toolExecution=nothing, ) - new( + # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) + input_ch = Channel(16) + followUp = Channel(32) + output_ch = Channel(16) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( agentState(systemPrompt, model, tools, messages), - Channel(16), + input_ch, + followUp, + output_ch, + nothing, # placeholder — replaced below formatMsgForLLM, preprocessMessages, - onPayload, - onResponse, beforeToolCall, afterToolCall, prepareNextTurn, @@ -227,6 +241,129 @@ function yiemAgent( maxRetryDelayMs, toolExecution, ) + + # Spawn the background loop and attach it + agent._task = @spawn _agent_loop(agent) + + return agent +end + +# ============================================================================ +# Agent loop — runs in background, processes messages from input_ch / followUp +# ============================================================================ + +""" +Private agent loop. Runs in a background @task. +Waits on input_ch and followUpQueue concurrently via select(). +""" +function _agent_loop(agent::yiemAgent) + try + while true + # Wait on either channel — the one with a message fires first + msg = select(agent.input_ch, agent.followUpQueue).val + + # Check for shutdown signal + if msg === :shutdown + break + end + + # Dispatch message through the processing pipeline + result = _process_message(agent, msg) + + # Send response to user + put!(agent.output_ch, result) + end + catch e + # On any error, send error response and exit the loop + try + put!(agent.output_ch, assistantMessage( + role="assistant", + content=[textContent("Agent error: $(sprint(showerror, e))")], + api="", model="", usage=nothing, + stopReason="error", + errorMessage=strip(sprint(showerror, e)), + timestamp=now(), + )) + catch e2 + @error "Failed to send error response" error=e2 + end + end +end + +""" +Process a single message through the agent pipeline. +This is where you add your LLM call, tool execution, etc. +""" +function _process_message(agent::yiemAgent, msg) + # TODO: Replace with actual processing logic + # + # 1. Add msg to agent._state.messages + # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM + # 3. If preprocessMessages is set, call agent.preprocessMessages(...) + # 4. Call the LLM (blocking — the task waits here) + # 5. If agent has tools, handle tool calls in a loop + # 6. Build assistantMessage and return it + + # Placeholder: echo back the message as a simple response + @warn "TODO: implement _process_message" + return assistantMessage( + role="assistant", + content=[textContent("Received: $(msg)")], + api="", model="", usage=nothing, + stopReason="end_turn", + errorMessage=nothing, + timestamp=now(), + ) +end + +# ============================================================================ +# Public API — interaction helpers +# ============================================================================ + +""" +Send a message to the agent's input channel. +Blocks if the input channel buffer is full (capacity 16 by default). +""" +function run_agent(agent::yiemAgent, msg) + put!(agent.input_ch, msg) + return agent +end + +""" +Take a response from the agent's output channel. +Blocks until the agent sends a response. +""" +function take_response(agent::yiemAgent) + return take!(agent.output_ch) +end + +""" +Send a follow-up message while the agent is still processing. +Follow-up messages are processed after all input_ch messages +and before any tool call results are sent. +""" +function follow_up(agent::yiemAgent, msg) + put!(agent.followUpQueue, msg) + return agent +end + +""" +Gracefully stop the agent. +Sends a :shutdown signal, waits for the task to finish, then closes all channels. +""" +function stop_agent(agent::yiemAgent) + put!(agent.input_ch, :shutdown) + try + fetch(agent._task) + catch e + if e isa TaskFailedException + rethrow(e) + end + end + close(agent.input_ch) + close(agent.output_ch) + close(agent.followUpQueue) + return nothing end -- 2.52.0 From c626d2cec5d39da8324ab28f82096bbd1e9f920c Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 3 Aug 2026 07:27:06 +0700 Subject: [PATCH 009/112] update --- src/type.jl | 52 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/type.jl b/src/type.jl index c206fe4..0f70ad1 100644 --- a/src/type.jl +++ b/src/type.jl @@ -6,6 +6,17 @@ module type using Dates, UUIDs, DataStructures, JSON, NATS using GeneralUtils +# ============================================================================ +# Simple type aliases / definitions +# ============================================================================ + +const Timestamp = DateTime + +struct Usage + inputTokens::Int64 + outputTokens::Int64 +end + # ---------------------------------------------- 100 --------------------------------------------- # @@ -20,6 +31,10 @@ struct userMessage <: agentMessage # Message from the user timestamp::Timestamp # When the message was sent end +function userMessage(; role="user", content=Vector{messageContent}(), timestamp=now()) + return userMessage(role, content, timestamp) +end + struct assistantMessage <: agentMessage # Message from the AI assistant role::String # Always "assistant" content::Vector{messageContent} # Text and/or image content @@ -32,6 +47,12 @@ struct assistantMessage <: agentMessage # Message from the AI assistant timestamp::Timestamp # When the message was received end +function assistantMessage(; role="assistant", content=Vector{messageContent}(), + api="", provider="", model="", usage=Usage(0, 0), stopReason="end_turn", + errorMessage=nothing, timestamp=now()) + return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) +end + struct toolResultMessage <: agentMessage # Result returned from a tool execution role::String # Always "tool" toolCallId::String # ID matching the tool call @@ -44,6 +65,12 @@ struct toolResultMessage <: agentMessage # Result returned from a tool execut timestamp::Timestamp # When the result was recorded end +function toolResultMessage(; role="tool", toolCallId="", toolName="", + content=Vector{messageContent}(), details=nothing, usage=nothing, + addedToolNames=nothing, isError=false, timestamp=now()) + return toolResultMessage(role, toolCallId, toolName, content, details, usage, addedToolNames, isError, timestamp) +end + # ============================================================================ # Message content types @@ -55,11 +82,19 @@ struct textContent <: messageContent # Plain text message content text::String # The text content end +function textContent(; text="") + return textContent(text) +end + struct imageContent <: messageContent # Image message content data::String # Base64-encoded image data mimeType::String # MIME type (e.g., "image/png") end +function imageContent(; data="", mimeType="") + return imageContent(data, mimeType) +end + # ============================================================================ # Tool types @@ -256,7 +291,7 @@ end Private agent loop. Runs in a background @task. Waits on input_ch and followUpQueue concurrently via select(). """ -function _agent_loop(agent::yiemAgent) +function _agent_loop(agent::yiemAgent) #WORKING try while true # Wait on either channel — the one with a message fires first @@ -275,18 +310,7 @@ function _agent_loop(agent::yiemAgent) end catch e # On any error, send error response and exit the loop - try - put!(agent.output_ch, assistantMessage( - role="assistant", - content=[textContent("Agent error: $(sprint(showerror, e))")], - api="", model="", usage=nothing, - stopReason="error", - errorMessage=strip(sprint(showerror, e)), - timestamp=now(), - )) - catch e2 - @error "Failed to send error response" error=e2 - end + @error "Agent loop failed" error=e end end @@ -295,7 +319,7 @@ Process a single message through the agent pipeline. This is where you add your LLM call, tool execution, etc. """ function _process_message(agent::yiemAgent, msg) - # TODO: Replace with actual processing logic + # PENDING Replace with actual processing logic # # 1. Add msg to agent._state.messages # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM -- 2.52.0 From 92eee0716833e618869931a917dd5084042ec256 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 3 Aug 2026 11:23:45 +0700 Subject: [PATCH 010/112] update --- src/YiemAgent.jl | 7 +- src/api.jl | 329 +++++++ src/core.jl | 147 ++++ src/interface.jl | 1363 ----------------------------- src/type.jl | 258 +++--- {src => src_OLD}/OLD_interface.jl | 0 {src => src_OLD}/OLD_type.jl | 0 7 files changed, 624 insertions(+), 1480 deletions(-) create mode 100644 src/api.jl create mode 100644 src/core.jl delete mode 100644 src/interface.jl rename {src => src_OLD}/OLD_interface.jl (100%) rename {src => src_OLD}/OLD_type.jl (100%) diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 63951b9..d75b129 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -16,8 +16,11 @@ module YiemAgent include("llmfunction.jl") using .llmfunction - include("interface.jl") - using .interface + include("core.jl") + using .core + + include("api.jl") + using .api # ---------------------------------------------- 100 --------------------------------------------- # diff --git a/src/api.jl b/src/api.jl new file mode 100644 index 0000000..7894951 --- /dev/null +++ b/src/api.jl @@ -0,0 +1,329 @@ +module api + +export prompt + +using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, + DataFrames, Serde +using GeneralUtils +using ..type, ..util, ..llmfunction + +# ---------------------------------------------- 100 --------------------------------------------- # + + + +""" +Send a message to the agent's input channel. +Blocks if the input channel buffer is full (capacity 16 by default). +""" +function run_agent(agent::yiemAgent, msg) + put!(agent.input_ch, msg) + return agent +end + +""" +Take a response from the agent's output channel. +Blocks until the agent sends a response. +""" +function take_response(agent::yiemAgent) + return take!(agent.output_ch) +end + +""" +Send a follow-up message while the agent is still processing. +Follow-up messages are processed after all input_ch messages +and before any tool call results are sent. +""" +function follow_up(agent::yiemAgent, msg) + put!(agent.followUpQueue, msg) + return agent +end + +""" +Gracefully stop the agent. +Sends a :shutdown signal, waits for the task to finish, then closes all channels. +""" +function stop_agent(agent::yiemAgent) + put!(agent.input_ch, :shutdown) + try + fetch(agent._task) + catch e + if e isa TaskFailedException + rethrow(e) + end + end + close(agent.input_ch) + close(agent.output_ch) + close(agent.followUpQueue) + return nothing +end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" Recursively convert dictionary-like variable (e.g. JSON.Object) into an OrderedDict. + +The function walks any nested structure composed of `AbstractDict` (e.g., `JSON.Object`, +`Dict`, `OrderedDict`) and `AbstractArray` and produces a new tree where +every dictionary-like node is an `OrderedDict` and every array-like node is a `Vector{Any}`. +Scalar values (numbers, strings, booleans, `nothing`, etc.) are returned unchanged. +Does **not** mutate the input; it always allocates new containers. + +# Arguments +- `x` + Any Julia value. If `x` is an `AbstractDict` it will be converted to an `OrderedDict`; + if it is an `AbstractArray` its elements will be processed recursively. + +# Keyword Arguments +- `keytype::Type=Any` + The key type for the output OrderedDict. Use `String` for `OrderedDict{String,Any}`, + `Symbol` for `OrderedDict{Symbol,Any}`, or `Any` to preserve original key types. +- `sort_order::Union{Nothing, Vector}=nothing` + Vector of keys specifying the desired order. Keys are arranged in the specified order + first, followed by any remaining keys. + +# Return +- A newly allocated nested structure composed of `OrderedDict{keytype,Any}` and `Vector{Any}` + that mirrors the input shape but uses ordered Julia containers. + +# Notes +- The function treats any `AbstractDict` as a mapping source, so it works with + `JSON.Object`, `Dict`, `OrderedDict`, etc. +- Arrays are returned as `Vector{Any}` with their elements processed recursively. + +# Examples +```jldoctest +julia> using JSON, DataStructures +julia> d = Dict( + "a" => 4, + "b" => 6, + "c" => Dict( + "d"=>7, + :e=>Dict( + "f"=>"hey", + "g"=>Dict( + "world"=>[1, "2", 3, Dict(:dd=>4.7)] + ) + ) + ) + ) + +julia> jsonstring = JSON.json(d) +julia> A1 = JSON.parse(jsonstring) # A1 type is JSON.Object +julia> A2 = dictify(A1; keytype=String) +OrderedDict{String,Any} with 3 entries: + "a" => 4 + "b" => 6 + "c" => OrderedDict("d"=>7, "e"=>Dict("f"=>"hey", "g"=>Dict("world"=>[1, "2", 3, 4.7]))) + +julia> A3 = dictify(A1; keytype=Symbol) +OrderedDict{Symbol,Any} with 3 entries: + :a => 4 + :b => 6 + :c => OrderedDict(:d=>7, :e=>Dict("f"=>"hey", "g"=>Dict("world"=>[1, "2", 3, 4.7]))) + +julia> B1 = dictify(d; keytype=String) +OrderedDict{String, Any} with 3 entries: +``` + +**With sort_order:** +```jldoctest +julia> d = Dict("a"=>1, "b"=>2, "c"=>3) +julia> dictify(d; sort_order=["c", "a"]) +OrderedDict{String,Int} with 3 entries: + "c" => 3 + "a" => 1 + "b" => 2 +``` +""" +function dictify(x::T; keytype::Type=Any, sort_order::Union{Nothing, Vector}=nothing + )::OrderedDict where {T<:AbstractDict} + + # this function is example +end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +end # module interface + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core.jl b/src/core.jl new file mode 100644 index 0000000..382a76b --- /dev/null +++ b/src/core.jl @@ -0,0 +1,147 @@ +module core + +# export prompt + +using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, + DataFrames, Serde +using GeneralUtils +using ..type, ..util, ..llmfunction + +# ---------------------------------------------- 100 --------------------------------------------- # + +""" +Private agent loop. Runs in a background @task. +Waits on input_ch and followUpQueue, processing whichever has a message first. +""" +function _agent_loop(agent::yiemAgent) + try + while true + # Wait on either channel — the one with a message fires first + # Wait on either channel — the one with a message is taken first + msg = nothing + while msg === nothing + if isready(agent.input_ch) + msg = take!(agent.input_ch) + elseif isready(agent.followUpQueue) + msg = take!(agent.followUpQueue) + else + yield() + end + end + + # Check for shutdown signal + if msg === :shutdown + break + end + + # Dispatch message through the processing pipeline + result = _process_message(agent, msg) + + # Send response to user + put!(agent.output_ch, result) + end + catch e + # On any error, send error response and exit the loop + @error "Agent loop failed" error=e + end +end + +""" +Process a single message through the agent pipeline. +This is where you add your LLM call, tool execution, etc. +""" +function _process_message(agent::yiemAgent, msg) + # WORKING Replace with actual processing logic + # + # 1. Add msg to agent._state.messages + # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM + # 3. If preprocessMessages is set, call agent.preprocessMessages(...) + # 4. Call the LLM (blocking — the task waits here) + # 5. If agent has tools, handle tool calls in a loop + # 6. Build assistantMessage and return it + + # Placeholder: echo back the message as a simple response + @warn "TODO: implement _process_message" + return assistantMessage( + role="assistant", + content=[textContent("Received: $(msg)")], + api="", model="", usage=nothing, + stopReason="end_turn", + errorMessage=nothing, + timestamp=now(), + ) +end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +end # end of module \ No newline at end of file diff --git a/src/interface.jl b/src/interface.jl deleted file mode 100644 index 69a3b1b..0000000 --- a/src/interface.jl +++ /dev/null @@ -1,1363 +0,0 @@ -module interface - -export prompt - -using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde -using GeneralUtils -using ..type, ..util, ..llmfunction - -# ---------------------------------------------- 100 --------------------------------------------- # - - - - -""" Think and choose action - -# Arguments - - `config::T1` - config - - `state::T2` - a game state - -# Keyword Arguments - -# Return - - `thoughtdict::Dict` - -# Example -```jldoctest -julia> result = decisionMaker(agent) - -OrderedDict{String, Any} with 4 entries: - "plan" => "The user provided an image of a sparkling white wine (Asolo Prosecco Bella Principessa from Italy) and requested a search for similar wines in the inventory. According to store guidelines, I must st… - "action_name" => "SEARCH_WINE_DATABASE" - "action_input" => "Sparkling white wine from Italy" - "action_result" => "1) winery: Terrazze dell Etna, wine_name: Rose Brut. -``` -""" -function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 - ) where {T<:agent} - @info "YiemAgent decisionMaker() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - requiredKeys = ["plan", "action_name", "action_input"] - context = - """ - - - """ - - # add context to text of the latest message (in the front). - # use for loop because in openai format, each msg may contain both text and image. - for d in a.chathistory[end]["content"] - if d["type"] == "text" - d["text"] = context * d["text"] - break - end - end - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - """ - { - "model": "your-model.gguf", - "messages": [ ... ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "agent_action", - "strict": true, - "schema": { - "type": "object", - "properties": { - "think": { - "type": "string", - "description": "Your step-by-step reasoning process. Explain why you are choosing this action." - }, - "action_name": { - "type": "string", - "enum": ["search_web", "get_weather", "calculate_math"], - "description": "The exact name of the tool to execute." - }, - "action_input": { - "type": "object", - "properties": { - "query": { "type": ["string", "null"], "description": "For search_web" }, - "location": { "type": ["string", "null"], "description": "For get_weather" }, - "equation": { "type": ["string", "null"], "description": "For calculate_math" } - }, - "required": ["query", "location", "equation"], - "additionalProperties": false - } - }, - "required": ["think", "action_name", "action_input"], - "additionalProperties": false - } - } - } - } - """ - - # strict output format - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict("type"=> "string"), - "action_name"=> Dict("type"=> "string"), - "action_input"=> Dict("type"=> "string"), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", - "messages"=> a.chathistory, - "temperature"=> 0.7, - "response_format"=> response_format, - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.remove_french_accents(response) - # think, response = GeneralUtils.extractthink(response) - - # dollar sign in Julia means string interpolation - while occursin('$', response) - response = replace(response, '$' => "USD") - end - - # responsedict = nothing - # try - # responsedict = Serde.parse_yaml(response) - # catch e - # println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - # # check whether all answer's key points are in responsedict - # println("\n---") - # println(responsedict) - # println("---\n") - # ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - - # if !ispass - # errornote = errormsg - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)-> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - responsedict = JSON.parse(response) - - if responsedict["action_input"] == "CHAT_BOX" && - occursin("similar", responsedict["action_input"]) - - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - @info "YiemAgent decisionMaker() end " @__LINE__ - return responsedict - end - - # in case decisionMaker failed, force to use generatechat!() - responsedict = OrderedDict( - "plan"=> "N/A", - "action_name"=> "CHAT_BOX", - "action_input"=> "N/A" - ) - return responsedict -end - - - -""" Assigns a scalar value to each new child node to be used for selec- -tion and backpropagation. This value effectively quantifies the agent's progress in task completion, -serving as a heuristic to steer the search algorithm towards the most promising regions of the tree. - -# Arguments - - `state<:AbstractDict` - one of Yiem's agent - - `text2textInstructLLM::Function` - A function that handles communication to LLM service - -# Return - - `score::Integer` - -# Example -```jldoctest -julia> -``` - -# Signature -""" -function evaluator(a::T1, timeline, decisiondict, evaluateecontext - ) where {T1<:agent} - - systemmsg = - """ - - - You are a master sommelier of an online wine store. - - - - Under your supervision, a trainee sommelier is engaging with a store customer. Each time the customer speaks, the trainee will assess the situation, determine the next course of action, and pause to await your guidance before proceeding. - - - - Improve a trainee sommelier decision based on the store policy and guidelines while ensuring seamless interactions between the trainee and customers. - - - - trajectory: A conversation between your trainee and the customer that have occurred up until now - - evaluatee_context: The context that evaluatee use to make a decision - - evaluatee_decision: The decision made by the evaluatee, consists of the following elements: - "plan" is the trainee's plan - "action_name" is the name of the action taken, which can be one of the available tool name. - "action_input" is the input to the action. - - - - Use only infomation provided by the store policy and guidelines as a bedrocks for your response. - - - - The trainee's plan, action_name, and action_input must be logically consistent - - The trainee's action_input should be in a proper format as specified by the tools. - - The trainee's action name and action input should make sense. For example, if the trainee isn't finished talking, he shouldn't use the END_CONVER_GUIDELINE tool. - - - 1) trajectory_evaluation: Analyze the trajectory of a solution to answer the user's original question. - - Evaluate the correctness of each section and the overall trajectory based on the given question. - - Provide detailed reasoning and analysis, focusing on the latest thought, action, and observation. - - Incomplete trajectory are acceptable if the thoughts and actions up to that point are correct, even if the final answer isn't reached. - - Do not generate additional thoughts or actions. - 2) decision_evaluation: - - Examine how the trainee's decisions align with the store's policies and guidelines before proceeding. - 3) suggestion: Based store policy and guidelines, provide a suggestion for the immediate decision step only. - 4) approval: Can be "yes" or "no". "no" if the suggestion contradict the trainee's decision; otherwise, it is "yes". - - - - { - "trajectory_evaluation": "...", - "decision_evaluation": "...", - "suggestion": "...", - "approval": "...", - } - - - Let's begin! - """ - requiredKeys = [:trajectory_evaluation, :decision_evaluation, :approval, :suggestion] - errornote = "N/A" - - for attempt in 1:10 - evaluateecontext = replace(evaluateecontext, "" => "") - evaluateecontext = replace(evaluateecontext, "" => "") - - context = - """ - - - $timeline - - - $evaluateecontext - - - {plan: $(decisiondict["plan"]), action_name: $(decisiondict["action_name"]), action_input: $(decisiondict["action_input"])} - - P.S. $errornote - - """ - - unformatPrompt = - [ - Dict("name" => "system", "text" => systemmsg), - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(unformatPrompt, a.llmFormatName) - # add info - prompt = prompt * context - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - response = GeneralUtils.remove_french_accents(response) - # response = replace(response, '$'=>"USD") - think, response = GeneralUtils.extractthink(response) - - responsedict = nothing - try - responsedict = copy(JSON.parsefile(response)) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent evaluator() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if accepted_as_answer ∉ ["yes", "no"] # [PENDING] add errornote into the prompt - # error("generated accepted_as_answer has wrong format") - # end - - println("\nEvaluator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(Dict(responsedict)) - return responsedict - end - error("Evaluator failed to generate an evaluation, Response: \n$response\n<|End of error|>") -end - -""" Chat with llm. - -# Example userinput - -image_path = "test/large_image.png" -image_bytes = read(image_path) -base64_string = base64encode(image_bytes) - -# 2. Match the MIME type according to your file extension (e.g., png, jpeg) -mime_type = "image/png" -data1_uri = "data:;base64," - -# 3. Construct payload with the Data URI -message => Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Describe this image for me"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data_uri) - ) - ] - ) - -""" -function conversation(a::sommelier; userinput::Union{Dict{String, Any}, JSON.Object{String, Any}}, - maximumMsg=50, max_think_loop::Integer=3) - - @info "YiemAgent conversation() start " @__LINE__ - userinput = GeneralUtils.dictify(userinput; keytype=String, sort_order=["text"]) - - # find text in usermsg - usertext = nothing - for (i, d) in enumerate(userinput["content"]) - if d["type"] == "text" - d["text"] = GeneralUtils.remove_french_accents(d["text"]) - usertext = d["text"] - end - end - - if usertext == "newtopic" - clearhistory(a) - return "Okay. What shall we talk about?" - else - - # add usermsg to a.chathistory but how do I handle images? - addNewMessage(a, "user", userinput; maximumMsg=maximumMsg) - - # thinking loop until AI wants to communicate with the user - loopcount = 0 - while true - loopcount += 1 - if loopcount > max_think_loop - - thoughtdict, result_raw = generatechat!(a) - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - return response_to_frontend - end - - - thoughtdict, result_raw = think(a) - - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - """ intended message to send to frontend should have the following format. - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => "assistant_text_response"), - Dict( - "type" => "items_info", - "items_info" => [ - Dict( - "wine_name"=> "wine name 1", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - Dict( - "wine_name"=> "wine name 2", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - ] - ), - ] - ) - """ - - - return response_to_frontend - else # still in action - - action_name = thoughtdict["action_name"] - action_input = thoughtdict["action_input"] - - action_call = Dict{String, Any}( - "role" => "action_call", - "content" => [Dict("type" => "text", "text" => "{action_name: $action_name, action_input: $action_input}"),] - ) - - addNewMessage(a, "action_call", action_call; maximumMsg=maximumMsg) - - action_result = thoughtdict["action_result"] - actionresult = Dict{String, Any}( - "role" => "action_result", - "content" => [Dict("type" => "text", "text" => "$action_result"),] - ) - - addNewMessage(a, "actionresult", actionresult; maximumMsg=maximumMsg) - @info "YiemAgent conversation() end think count $loopcount " @__LINE__ - end - end - end -end - - -""" -# Arguments - -# Return - -# Example -```jldoctest -julia> -``` - -""" -function think(a::T)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - # a.memory[:recap] = generateSituationReport(a, a.context["text"2textInstructLLM]; skiprecent=0) - @info "YiemAgent think() start " @__LINE__ - thoughtdict = decisionMaker(a) - @info "YiemAgent think() 1 " @__LINE__ - @show thoughtdict - println("---\n") - - result_raw = nothing - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - - # sometime CHAT_BOX input is too short. - # if thoughtdict["action_input] < 20 character, use generatechat!() - if length(thoughtdict["action_input"]) < 20 - thoughtdict, result_raw = generatechat!(a) - else - thoughtdict["action_result"] = "Action result is the next user dialogue." - result_raw = thoughtdict["action_input"] - end - - elseif thoughtdict["action_name"] == "END_CONVER_GUIDELINE" - - thoughtdict, result_raw = end_conversation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] ∈ ["WINE_PRESENTATION_GUIDELINE"] - - thoughtdict, result_raw = wine_presentation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] == "SEARCH_WINE_DATABASE" - - thoughtdict, result_raw = search_wine_database!(a, thoughtdict; useSQLLLM=false) - if result_raw !== nothing && result_raw isa Vector - if haskey(a.memory["shortmem"], "items_info") - append!(a.memory["shortmem"]["items_info"], result_raw) - else - a.memory["shortmem"]["items_info"] = result_raw - end - end - - else - - error("condition is not defined ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - @info "YiemAgent think() end " @__LINE__ - # @show thoughtdict - println("---\n") - return (thoughtdict=thoughtdict, result_raw=result_raw) -end - -function chatbox!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - thoughtdict["action_result"] = "Action result is the next user dialogue." - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function end_conversation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide customer with store contact info and business hours - - Invite customer to comeback - - Business Hours: everyday 9.00-20.00 - Tel. 0863055790 - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function wine_presentation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide detailed introductions of the wines you've found to the user. - - Explain how the wine could match the user's intention and what its effects might mean for the user's experience. - - If multiple wines are available, highlight their differences and provide a comprehensive comparison of how each option aligns with the user's intention and what the potential effects of each option could mean for the user's experience. - - Provide your personal recommendation and provide a brief explanation of why you recommend it. - - People don't describe wine quality level in numbers so use convertion_table if neccessary - - Intensity level: - 1 to 2: May correspond to "light-bodied" or a similar description. - 2 to 3: May correspond to "med light bodied", "medium light" or a similar description. - 3 to 4: May correspond to "medium bodied" or a similar description. - 4 to 5: May correspond to "med full bodied", "medium full" or a similar description. - 4 to 5: May correspond to "full bodied" or a similar description. - Sweetness level: - 1 to 2: May correspond to "dry", "no sweet" or a similar description. - 2 to 3: May correspond to "off dry", "less sweet" or a similar description. - 3 to 4: May correspond to "semi sweet" or a similar description. - 4 to 5: May correspond to "sweet" or a similar description. - 4 to 5: May correspond to "very sweet" or a similar description. - Tannin level: - 1 to 2: May correspond to "low tannin" or a similar description. - 2 to 3: May correspond to "semi low tannin" or a similar description. - 3 to 4: May correspond to "medium tannin" or a similar description. - 4 to 5: May correspond to "semi high tannin" or a similar description. - 4 to 5: May correspond to "high tannin" or a similar description. - Acidity level: - 1 to 2: May correspond to "low acidity" or a similar description. - 2 to 3: May correspond to "semi low acidity" or a similar description. - 3 to 4: May correspond to "medium acidity" or a similar description. - 4 to 5: May correspond to "semi high acidity" or a similar description. - 4 to 5: May correspond to "high acidity" or a similar description. - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - - -#PENDING -function generatechat!(a::T; maxattempt::Integer=10 - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - @info "YiemAgent generatechat!() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user immediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - # situation - You are continuing the conversation with the user. - - # your role - Your name is $(a.name). You are a helpful sommelier for website-based $(a.retailername)'s wine store. You are working under your mentor supervision. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, continuing conversation with the customer using CHAT_BOX action. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", action_name must be CHAT_BOX. - 3) "action_input", Dialogue you want to chat with the user according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - chathistory = deepcopy(a.chathistory[2:end]) # use deep copy because I want to replace system msg - pushfirst!(chathistory, system_msg) - - requiredKeys = ["plan", "action_name", "action_input"] - - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - for attempt in 1:maxattempt - if attempt > 1 - println("\nYiemAgent generatechat() attempt $attempt/$maxattempt ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict( - "type"=> "string", - "description" => "Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.", - ), - "action_name"=> Dict( - "type"=> "string", - "description" => "action_name must be CHAT_BOX", - ), - "action_input"=> Dict( - "type"=> "string", - "description" => "Dialogue you want to chat with the user according to your plan.", - ), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => chathistory, - "temperature" => 0.7, - "response_format"=> response_format, - ) - - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - - response = strip(response) - - responsedict = nothing - if occursin(requiredKeys[2], response) - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - else - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - responsedict["action_result"] = "Action result is the next user dialogue." - @info "YiemAgent generatechat!() end " @__LINE__ - return (thoughtdict=responsedict, result_raw=responsedict["action_input"]) - end - @info "YiemAgent generatechat() failed to generate a thought " @__LINE__ - error("YiemAgent generatechat() failed to generate a thought ", response) -end - - -function generatequestion(a, text2textInstructLLM::Function, timeline)::String - systemmsg = - """ - Your role: - Your name is $(a.name). You are a helpful English-speaking, website-based sommelier for $(a.retailername)'s online store currently talking with the user. - Your goal includes: - 1) Help the user select the best wines from your inventory that align with the user's preferences - 2) Thanks the user when they don't need any further assistance and invite them to comeback next time - - Your responsibility includes: - 1) From your point of view as a sommelier helping the user, ask yourself multiple questions based on the current situation - - Your responsibility does NOT includes: - 1) Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - 2) Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - 3) Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - At each round of conversation, you will be given the info: - Additional info: ... - Your recent events: latest 5 events of the situation - - You must follow the following guidelines: - - Your question should be specific, self-contained and not require any additional context. - - Once the user has chose their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - You should follow the following guidelines: - - Focus on the latest conversation - - If the user interrupts, prioritize the user - - If you don't already know, find out the user's budget - - If you don't already know, find out the type of wine the user is looking for, such as red, white, sparkling, rose, dessert, fortified - - If you don't already know, find out the occasion for which the user is buying wine - - If you don't already know, find out the characteristics of wine the user is looking for, such as tannin, sweetness, intensity, acidity - - If you don't already know, find out what food will be served with wine - - If you haven't already, introduce the wines you found in the database to the user first - - Generally speaking, your inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - All wines in your inventory are always in stock. - - Engage in conversation to indirectly investigate the customer's intention, budget and preferences before checking your inventory. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - Medium and full-bodied red wines are bad with spicy foods. - - If a customer requests information about discounts, quantity, rewards programs, promotions, delivery options, boxes, gift wrapping, packaging, or personalized messages, please inform them that they can contact our sales team at the store. - - You should then respond to the user with: - 1) Thought: State your thought about the current situation - 2) Q: "Ask yourself" at least three, but no more than five, questions about the situation from your perspective. - 3) A: Given the situation, "answer to yourself" the best you can. Do not generate any extra text after you finish answering all questions - - You must only respond in format as described below: - Q1: ... - A1: ... - Q2: ... - A2: ... - ... - - Here are some examples: - Q: What the user is looking for? - A: The user is asking for a MPV car with 7-seat - Q: What do I know? - A: The user is looking for a car with 7-seat. Our dealer sell these kind of cars - Q: What brands the user prefer? - A: I don't know. The user didn't mentioned that. Let's find out. - Q: What else do I need to know before proceeding? - A: I don't know about the user budget, car's color, and other user's preferences yet. Let's find out more about the user's preferences. - Q: I'm still lacking information regarding the user's preferences for the powertrain. I've asked the user twice already, but perhaps they're not familiar with this. What should I do. - A: I'll proceed without asking the user about the powertrain. - Q: The user is buying for her husband, should I dig in to get more information? - A: Yes, I should. So that I have better idea about the user's preferences. - Q: Why the user saying this? - A: The user does not want an SUV because it does not have sliding doors - Q: The user is asking for a cappuccino. Do I have it at my cafe? - A: No I don't have. - Q: Since I don't have a cappuccino but I have a Late, should I ask if they are okay with that? - A: Yes, I should. - Q: Are they allergic to milk? - A: Since they mentioned a cappuccino before, it seems they are not allergic to milk. - Q: Have I checked the inventory yet? - A: No. I need more information from the user including ... - Q: What else do I need to know? - A: ... - Q: Should I present my item to the user? - A: Not yet, I will need to check my inventory first. - Q: Should I check our inventory now? - A: ... - Q: What the user intend to do with the car? - A: I don't know yet. Let's ask the user. - Q: What do I have in our inventory? - A: ... - Q: Which items are within the user price range? And which items are out of the user price rance? - A: ... - Q: Do I have what the user is looking for in our stock? - A: ... - Q: Am I certain about the information I'm going to share with the user, or should I verify the information first? - A: ... - Q: What should I do? - A: ... - Q: What shouldn't I do? - A: ... - Q: what kind of car suitable for off-road trip? - A: A four-wheel drive SUV is a good choice for off-road trips. - Q: What car specification would satisfy the user's needs? - A: The user is seeking an eco-friendly vehicle that accommodates seven passengers, including seniors and children, with prioritized accessibility and efficient refueling. While electric vehicles (EVs) offer eco-friendly benefits, their long charging times make hybrid models more practical for fast refueling. Additionally, a lower ground level is essential for ease of entry/exit for seniors and children. A hybrid multi-purpose vehicle (MPV) emerges as the optimal solution, balancing sustainability, seating capacity, accessibility, and refueling efficiency. - - Let's begin! - """ - - header = ["Q1:"] - dictkey = ["q1"] - - # context = - # if length(a.memory["shortmem"]["available_wine"]) != 0 - # "Available wines you've found in your inventory so far: $(availableWineToText(a.memory["shortmem"]["available_wine"]))" - # else - # "N/A" - # end - database_search_result = a.memory["shortmem"]["db_search_result"] - - # recent_ind = GeneralUtils.recentElementsIndex(length(a.memory[:events]), recent) - # recentevents = a.memory[:events][recent_ind] - # timeline = createTimeline(recentevents; eventindex=recent_ind) - errornote = "N/A" - response = nothing # store for show when error msg show up - - # recap = - # if length(a.memory[:recap]) <= recent - # "N/A" - # else - # recapkeys = keys(a.memory[:recap]) - # recapkeys_vec = [i for i in recapkeys] - # recapkeys_vec = recapkeys_vec[1:end-recent] - # tempmem = OrderedDict() - # for (k, v) in a.memory[:recap] - # if k ∈ recapkeys_vec - # tempmem[k] = v - # end - # end - - # GeneralUtils.dictToString(tempmem) - # end - - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.5, - ) - - for attempt in 1:10 - if attempt > 1 - println("\nYiemAgent generatequestion() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = - """ - Additional info: $database_search_result - Your recent events: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = text2textInstructLLM(prompt; - modelsize="medium", llmkwargs=llmkwargs, senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - - # make sure generatequestion() don't have wine name that is not from retailer inventory - # check whether an agent recommend wines before checking inventory or recommend wines - # outside its inventory - # ask LLM whether there are any winery mentioned in the response - mentioned_winery = detectWineryName(a, response) - if mentioned_winery != "None" - mentioned_winery = String.(strip.(split(mentioned_winery, ","))) - - # check whether the wine is in event - isWineInEvent = false - for winename in mentioned_winery - for event in a.memory["events"] - if event["observation"] !== nothing && occursin(winename, event["observation"]) - isWineInEvent = true - break - end - end - end - - # if wine is mentioned but not in timeline or shortmem, - # then the agent is not supposed to recommend the wine - if isWineInEvent == false - errornote = "Your previous attempt mentioned wines that are not in your inventory which is not allowed." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - end - - q_number = count("Q", response) - - # check for valid response - if q_number < 1 - errornote = "Your previous attempt has too few questions." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - # check whether "A1" is in the response, if not error. - elseif !occursin("A1:", response) - errornote = "Your previous attempt does not have A1:" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - if 0 ∈ values(detected_kw) - errornote = "\nYour previous attempt did not have all points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - response = "Q1: " * responsedict["q1"] - println("\nYiemAgent generatequestion() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return response - end - error("YiemAgent generatequestion() failed to generate a response ", response) -end - - -function generateSituationReport(a, text2textInstructLLM::Function; skiprecent::Integer=0 - )::OrderedDict - - systemmsg = - """ - You are an assistant being in the given events. - Your task is to writes a summary for each event seperately into an ongoing, interleaving series. - - At each round of conversation, you will be given the situation: - Total events: number of events you need to summarize. - Events timeline: ... - Context: ... - - You should follow the following guidelines: - - Use the word "user" and "assistant" instead of their name in the report - - You should then respond to the user with the following: - Event: a detailed summary for each event without exaggerated details. - - You must only respond in format as described below: - Event_1: ... - Event_2: ... - ... - - Here are some examples: - Event_1: The user ask me about where to buy a toy. - Event_2: I told the user to go to the store at 2nd floor. - - Event_1: The user greets the assistant by saying 'hello'. - Event_2: The assistant respond warmly and inquire about how he can assist the user. - - Let's begin! - """ - - header = ["Event_$i:" for i in eachindex(a.memory["events"])] - dictkey = lowercase.(["Event_$i" for i in eachindex(a.memory["events"])]) - - ind = GeneralUtils.nonRecentElementsIndex(length(a.memory["events"]), skiprecent) - events = a.memory["events"][ind] - timeline = createTimeline(events) - - errornote = "N/A" - response = nothing # store for show when error msg show up - for attempt in 1:10 - if attempt > 1 # use to prevent LLM generate the same respond over and over - println("\nYiemAgent generateSituationReport() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = """ - Total events: $(length(events)) - Events timeline: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3") - - response = text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, "qwen3") - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - kwvalue = [i for i in values(detected_kw)] - zeroind = findall(x -> x == 0, kwvalue) - missingkeys = [header[i] for i in zeroind] - if 0 ∈ values(detected_kw) - errornote = "$missingkeys are missing in your previous attempt" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "Your previous response has duplicated events" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - println("\ngenerateSituationReport() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return responsedict - end - error("generateSituationReport failed to generate a response ", response) -end - - -function detectWineryName(a, text) - systemmsg = - """ - You are a sommelier of a wine store. - Your task is to identify and list any winery names mentioned in the provided text. - - At each round of conversation, you will be given the situation: - Text: a text describing the situation. - - Tips: - - Winery usually contains Château, Chateau, Domaine, Côte, Cotes, St. de, or a combination of these words. - - You should then respond to the user with: - Winery_names: A list of winery names mentioned in the text or "None" if no winery name is mentioned. - - You must only respond in format as described below: - Winery_names: ... - - Here are some examples: - Winery_names: Domaine Courbis, Chateau Lafite Rothschild, Matarromera Domaine Roulot, Château, Cotes - - Let's begin! - """ - - header = ["Winery_names:"] - dictkey = ["winery_names"] - - response = nothing # placeholder for show when error msg show up - - for attempt in 1:10 - usermsg = """ - Text: $text - """ - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - println("\ndetectWineryName() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - missingkeys = [k for (k, v) in detected_kw if v === nothing] - - if !isempty(missingkeys) - errornote = "$missingkeys are missing from your previous response" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum([length(i) for i in values(detected_kw)]) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - result = responsedict["winery_names"] - - return result - end - error("detectWineryName failed to generate a response") - end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module interface - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/type.jl b/src/type.jl index 0f70ad1..9b5cb62 100644 --- a/src/type.jl +++ b/src/type.jl @@ -31,6 +31,23 @@ struct userMessage <: agentMessage # Message from the user timestamp::Timestamp # When the message was sent end +""" +Create a new user message. + +# Arguments +- `role::String`: Always "user" +- `content::Vector{messageContent}`: Text and/or image content +- `timestamp::Timestamp`: When the message was sent + +# Returns +- A new `userMessage` instance + +# Examples +```julia +julia> msg = userMessage(content=[textContent("Hello")]) +userMessage("user", [textContent("Hello")], DateTime(...)) +``` +""" function userMessage(; role="user", content=Vector{messageContent}(), timestamp=now()) return userMessage(role, content, timestamp) end @@ -47,6 +64,29 @@ struct assistantMessage <: agentMessage # Message from the AI assistant timestamp::Timestamp # When the message was received end +""" +Create a new assistant message. + +# Arguments +- `role::String`: Always "assistant" +- `content::Vector{messageContent}`: Text and/or image content +- `api::String`: API name used (e.g., "openai") +- `provider::String`: Provider name (e.g., "anthropic") +- `model::String`: Model identifier +- `usage::Usage`: Token usage for this message +- `stopReason::String`: Why generation stopped (e.g., "end_turn") +- `errorMessage::Union{String, Nothing}`: Error if generation failed +- `timestamp::Timestamp`: When the message was received + +# Returns +- A new `assistantMessage` instance + +# Examples +```julia +julia> msg = assistantMessage(content=[textContent("Hello!")], model="gpt-4") +assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "end_turn", nothing, DateTime(...)) +``` +""" function assistantMessage(; role="assistant", content=Vector{messageContent}(), api="", provider="", model="", usage=Usage(0, 0), stopReason="end_turn", errorMessage=nothing, timestamp=now()) @@ -65,6 +105,29 @@ struct toolResultMessage <: agentMessage # Result returned from a tool execut timestamp::Timestamp # When the result was recorded end +""" +Create a new tool result message. + +# Arguments +- `role::String`: Always "tool" +- `toolCallId::String`: ID matching the tool call +- `toolName::String`: Name of the executed tool +- `content::Vector{messageContent}`: Tool output content +- `details::Any`: Additional tool-specific details +- `usage::Union{Usage, Nothing}`: Token usage if applicable +- `addedToolNames::Union{Vector{String}, Nothing}`: Tools added during execution +- `isError::Bool`: Whether the tool call resulted in an error +- `timestamp::Timestamp`: When the result was recorded + +# Returns +- A new `toolResultMessage` instance + +# Examples +```julia +julia> msg = toolResultMessage(toolCallId="call_123", toolName="search", content=[textContent("results")]) +toolResultMessage("tool", "call_123", "search", [textContent("results")], nothing, nothing, nothing, false, DateTime(...)) +``` +""" function toolResultMessage(; role="tool", toolCallId="", toolName="", content=Vector{messageContent}(), details=nothing, usage=nothing, addedToolNames=nothing, isError=false, timestamp=now()) @@ -82,6 +145,21 @@ struct textContent <: messageContent # Plain text message content text::String # The text content end +""" +Create plain text message content. + +# Arguments +- `text::String`: The text content + +# Returns +- A new `textContent` instance + +# Examples +```julia +julia> content = textContent("Hello, world!") +textContent("Hello, world!") +``` +""" function textContent(; text="") return textContent(text) end @@ -91,6 +169,22 @@ struct imageContent <: messageContent # Image message content mimeType::String # MIME type (e.g., "image/png") end +""" +Create image message content. + +# Arguments +- `data::String`: Base64-encoded image data +- `mimeType::String`: MIME type (e.g., "image/png") + +# Returns +- A new `imageContent` instance + +# Examples +```julia +julia> img = imageContent(data="base64data...", mimeType="image/png") +imageContent("base64data...", "image/png") +``` +""" function imageContent(; data="", mimeType="") return imageContent(data, mimeType) end @@ -135,6 +229,27 @@ mutable struct agentState # Mutable runtime state of an agen errorMessage::Union{String, Nothing} # Last error message end +""" +Create a new mutable agent state. + +Creates a deep copy of the provided tools and messages to isolate the +new state from external references. + +# Arguments +- `systemPrompt::String`: System prompt text +- `model::llmModel`: LLM model to use (defaults to an unknown model) +- `tools::Vector{agentTool}`: Available tools (deep copied) +- `messages::Vector{agentMessage}`: Conversation messages (deep copied) + +# Returns +- A new `agentState` instance with an empty pending tool calls list and no error + +# Examples +```julia +julia> state = agentState(systemPrompt="You are a helpful assistant") +agentState("You are a helpful assistant", ..., agentTool[], agentMessage[], String[], nothing) +``` +""" function agentState( systemPrompt::String="", model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), @@ -237,7 +352,34 @@ mutable struct yiemAgent <: agent # High-level agent wrapper end """ -docstring +Create a new yiemAgent instance with a background loop task. + +Spawns a background `@spawn` task that runs the agent loop, listening +on `input_ch` and `followUpQueue` channels concurrently. + +# Keyword Arguments +- `systemPrompt::String`: System prompt for the agent +- `model`: LLM model to use +- `tools::Vector{agentTool}`: Available tools (default: empty) +- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) +- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) +- `preprocessMessages::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) +- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) +- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) +- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) +- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) +- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) +- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) +- `toolExecution`: Default tool execution mode (sequential or parallel) (default: `nothing`) + +# Returns +- A new `yiemAgent` instance with an active background task + +# Examples +```julia +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) +``` """ function yiemAgent( ; systemPrompt::String="", @@ -283,120 +425,6 @@ function yiemAgent( return agent end -# ============================================================================ -# Agent loop — runs in background, processes messages from input_ch / followUp -# ============================================================================ - -""" -Private agent loop. Runs in a background @task. -Waits on input_ch and followUpQueue concurrently via select(). -""" -function _agent_loop(agent::yiemAgent) #WORKING - try - while true - # Wait on either channel — the one with a message fires first - msg = select(agent.input_ch, agent.followUpQueue).val - - # Check for shutdown signal - if msg === :shutdown - break - end - - # Dispatch message through the processing pipeline - result = _process_message(agent, msg) - - # Send response to user - put!(agent.output_ch, result) - end - catch e - # On any error, send error response and exit the loop - @error "Agent loop failed" error=e - end -end - -""" -Process a single message through the agent pipeline. -This is where you add your LLM call, tool execution, etc. -""" -function _process_message(agent::yiemAgent, msg) - # PENDING Replace with actual processing logic - # - # 1. Add msg to agent._state.messages - # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM - # 3. If preprocessMessages is set, call agent.preprocessMessages(...) - # 4. Call the LLM (blocking — the task waits here) - # 5. If agent has tools, handle tool calls in a loop - # 6. Build assistantMessage and return it - - # Placeholder: echo back the message as a simple response - @warn "TODO: implement _process_message" - return assistantMessage( - role="assistant", - content=[textContent("Received: $(msg)")], - api="", model="", usage=nothing, - stopReason="end_turn", - errorMessage=nothing, - timestamp=now(), - ) -end - -# ============================================================================ -# Public API — interaction helpers -# ============================================================================ - -""" -Send a message to the agent's input channel. -Blocks if the input channel buffer is full (capacity 16 by default). -""" -function run_agent(agent::yiemAgent, msg) - put!(agent.input_ch, msg) - return agent -end - -""" -Take a response from the agent's output channel. -Blocks until the agent sends a response. -""" -function take_response(agent::yiemAgent) - return take!(agent.output_ch) -end - -""" -Send a follow-up message while the agent is still processing. -Follow-up messages are processed after all input_ch messages -and before any tool call results are sent. -""" -function follow_up(agent::yiemAgent, msg) - put!(agent.followUpQueue, msg) - return agent -end - -""" -Gracefully stop the agent. -Sends a :shutdown signal, waits for the task to finish, then closes all channels. -""" -function stop_agent(agent::yiemAgent) - put!(agent.input_ch, :shutdown) - try - fetch(agent._task) - catch e - if e isa TaskFailedException - rethrow(e) - end - end - close(agent.input_ch) - close(agent.output_ch) - close(agent.followUpQueue) - return nothing -end - - - - - - - - diff --git a/src/OLD_interface.jl b/src_OLD/OLD_interface.jl similarity index 100% rename from src/OLD_interface.jl rename to src_OLD/OLD_interface.jl diff --git a/src/OLD_type.jl b/src_OLD/OLD_type.jl similarity index 100% rename from src/OLD_type.jl rename to src_OLD/OLD_type.jl -- 2.52.0 From 569f85333fba63c5b50d298dd7ec01679a38bf51 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 3 Aug 2026 18:03:51 +0700 Subject: [PATCH 011/112] update --- src/YiemAgent.jl | 4 +- src/agentCore.jl | 203 +++++++++++++++++++++++ src/api.jl | 76 ++++++++- src/core.jl | 147 ----------------- src/llmfunction.jl | 390 ++++++++++++++++++++++++++++++++++++++------- src/type.jl | 48 ++++-- src/util.jl | 262 +++++++++++++++++------------- 7 files changed, 806 insertions(+), 324 deletions(-) create mode 100644 src/agentCore.jl delete mode 100644 src/core.jl diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index d75b129..822e9fb 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -16,8 +16,8 @@ module YiemAgent include("llmfunction.jl") using .llmfunction - include("core.jl") - using .core + include("agentCore.jl") + using .agentCore include("api.jl") using .api diff --git a/src/agentCore.jl b/src/agentCore.jl new file mode 100644 index 0000000..c5cde25 --- /dev/null +++ b/src/agentCore.jl @@ -0,0 +1,203 @@ +module agentCore + +# export prompt + +using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, + DataFrames, Serde +using GeneralUtils +using ..type, ..util, ..llmfunction + +# ---------------------------------------------- 100 --------------------------------------------- # + +""" +Private agent loop. Runs in a background `@spawn` task. + +Waits on `input_ch` and `followUpQueue`, processing whichever has a message first. +On each iteration, dispatches the message through `_process_message` and sends the result +to `output_ch`. Exits on `:shutdown` signal. + +# Arguments +- `agent::yiemAgent`: The agent whose loop to run + +# Returns +- `nothing` — the loop runs until `:shutdown` is received or an error occurs + +# Notes +- This function is automatically spawned as a background task when a `yiemAgent` is created. +- On any error, logs the error with `@error` and exits the loop. +- Message priority: `input_ch` messages are checked before `followUpQueue` messages. + +# Examples +```jldoctest +julia> # Called automatically by yiemAgent constructor +``` +""" +function _agent_loop(agent::yiemAgent) #WORKING + try + while true + # Wait on either channel — the one with a message fires first + # Wait on either channel — the one with a message is taken first + msg = nothing + while msg === nothing + if isready(agent.input_ch) + msg = take!(agent.input_ch) + else + yield() + end + end + + # Check for shutdown signal + if msg === :shutdown + #TODO make sure every running tools ended properly + break + end + + #TODO convert raw user msg to userMessage type + + #TODO add userMessage to agent._state.messages + + + if isready(agent.followUpQueue) + msg = take!(agent.followUpQueue) + end + + # @spawn. Dispatch message through the processing pipeline. + result = _process_message(agent, msg) + + # Send response to user + put!(agent.output_ch, result) + end + catch e + # On any error, send error response and exit the loop + @error "Agent loop failed" error=e + end +end + +""" +Process a single message through the agent pipeline. + +This is the core processing function where LLM calls, tool execution, and response generation +should be implemented. Currently a placeholder that echoes back the received message. + +# Arguments +- `agent::yiemAgent`: The agent processing the message +- `msg`: The message to process (from `input_ch` or `followUpQueue`) + +# Returns +- An `assistantMessage` instance with the processed response + +# Notes +- Implement the full processing pipeline: + 1. Add `msg` to `agent._state.messages` + 2. Call `agent.formatMsgForLLM(agent._state)` 1 to format for LLM + 3. If `agent.preprocessMessages` is set, call it on the formatted messages + 4. Call the LLM (blocking — the task waits here) + 5. If agent has tools, handle tool calls in a loop + 6. Build `assistantMessage` and return it + +# Examples +```jldoctest +julia> # Currently returns a placeholder echo response +``` +""" +function _process_message(agent::yiemAgent, msg) + # WORKING Replace with actual processing logic + # check steering message + + + + + + + # 1. call agent. + # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM + # 3. If preprocessMessages is set, call agent.preprocessMessages(...) + # 4. Call the LLM (blocking — the task waits here) + # 5. If agent has tools, handle tool calls in a loop + # 6. Build assistantMessage and return it + + # Placeholder: echo back the message as a simple response + @warn "TODO: implement _process_message" + return assistantMessage( + role="assistant", + content=[textContent("Received: $(msg)")], + api="", model="", usage=nothing, + stopReason="end_turn", + errorMessage=nothing, + timestamp=now(), + ) +end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +end # end of module \ No newline at end of file diff --git a/src/api.jl b/src/api.jl index 7894951..f8769bb 100644 --- a/src/api.jl +++ b/src/api.jl @@ -13,7 +13,26 @@ using ..type, ..util, ..llmfunction """ Send a message to the agent's input channel. + Blocks if the input channel buffer is full (capacity 16 by default). +The agent processes messages from `input_ch` in the background task. + +# Arguments +- `agent::yiemAgent`: The agent instance to send a message to +- `msg`: The message to send (any type accepted by the agent's processing pipeline) + +# Returns +- The same `agent` instance for chaining + +# Notes +- Use `take_response(agent)` to receive the agent's response after sending a message. +- Use `follow_up(agent, msg)` to send messages while the agent is still processing. + +# Examples +```jldoctest +julia> run_agent(agent, "Hello!") +yiemAgent(...) +``` """ function run_agent(agent::yiemAgent, msg) put!(agent.input_ch, msg) @@ -22,7 +41,23 @@ end """ Take a response from the agent's output channel. + Blocks until the agent sends a response. + +# Arguments +- `agent::yiemAgent`: The agent instance to receive a response from + +# Returns +- An `assistantMessage` instance representing the agent's response + +# Notes +- Use `run_agent(agent, msg)` to send a message before calling this function. + +# Examples +```jldoctest +julia> response = take_response(agent) +assistantMessage(...) +``` """ function take_response(agent::yiemAgent) return take!(agent.output_ch) @@ -30,8 +65,27 @@ end """ Send a follow-up message while the agent is still processing. -Follow-up messages are processed after all input_ch messages + +Follow-up messages are queued and processed after all `input_ch` messages and before any tool call results are sent. + +# Arguments +- `agent::yiemAgent`: The agent instance to send a follow-up message to +- `msg`: The follow-up message to send + +# Returns +- The same `agent` instance for chaining + +# Notes +- Use `run_agent(agent, msg)` for the primary message and `follow_up(agent, msg)` for additional + messages while the agent is processing. +- Follow-up messages are buffered in a separate channel (capacity 32 by default). + +# Examples +```jldoctest +julia> follow_up(agent, "Also consider red wines") +yiemAgent(...) +``` """ function follow_up(agent::yiemAgent, msg) put!(agent.followUpQueue, msg) @@ -40,7 +94,25 @@ end """ Gracefully stop the agent. -Sends a :shutdown signal, waits for the task to finish, then closes all channels. + +Sends a `:shutdown` signal to the input channel, waits for the background task to finish, +then closes all channels (`input_ch`, `output_ch`, `followUpQueue`). + +# Arguments +- `agent::yiemAgent`: The agent instance to stop + +# Returns +- `nothing` + +# Notes +- After calling `stop_agent`, the agent is no longer usable. A new agent must be created + for further interaction. +- If the background task throws a `TaskFailedException`, it is rethrown. + +# Examples +```jldoctest +julia> stop_agent(agent) +``` """ function stop_agent(agent::yiemAgent) put!(agent.input_ch, :shutdown) diff --git a/src/core.jl b/src/core.jl deleted file mode 100644 index 382a76b..0000000 --- a/src/core.jl +++ /dev/null @@ -1,147 +0,0 @@ -module core - -# export prompt - -using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde -using GeneralUtils -using ..type, ..util, ..llmfunction - -# ---------------------------------------------- 100 --------------------------------------------- # - -""" -Private agent loop. Runs in a background @task. -Waits on input_ch and followUpQueue, processing whichever has a message first. -""" -function _agent_loop(agent::yiemAgent) - try - while true - # Wait on either channel — the one with a message fires first - # Wait on either channel — the one with a message is taken first - msg = nothing - while msg === nothing - if isready(agent.input_ch) - msg = take!(agent.input_ch) - elseif isready(agent.followUpQueue) - msg = take!(agent.followUpQueue) - else - yield() - end - end - - # Check for shutdown signal - if msg === :shutdown - break - end - - # Dispatch message through the processing pipeline - result = _process_message(agent, msg) - - # Send response to user - put!(agent.output_ch, result) - end - catch e - # On any error, send error response and exit the loop - @error "Agent loop failed" error=e - end -end - -""" -Process a single message through the agent pipeline. -This is where you add your LLM call, tool execution, etc. -""" -function _process_message(agent::yiemAgent, msg) - # WORKING Replace with actual processing logic - # - # 1. Add msg to agent._state.messages - # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM - # 3. If preprocessMessages is set, call agent.preprocessMessages(...) - # 4. Call the LLM (blocking — the task waits here) - # 5. If agent has tools, handle tool calls in a loop - # 6. Build assistantMessage and return it - - # Placeholder: echo back the message as a simple response - @warn "TODO: implement _process_message" - return assistantMessage( - role="assistant", - content=[textContent("Received: $(msg)")], - api="", model="", usage=nothing, - stopReason="end_turn", - errorMessage=nothing, - timestamp=now(), - ) -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # end of module \ No newline at end of file diff --git a/src/llmfunction.jl b/src/llmfunction.jl index 55ef96f..2cc1b3d 100644 --- a/src/llmfunction.jl +++ b/src/llmfunction.jl @@ -12,27 +12,29 @@ using ..type, ..util # ---------------------------------------------- 100 --------------------------------------------- # -""" Chatbox for chatting with virtual wine customer. +""" +Chat with a virtual wine customer for recommendations. + +Formats the input for the configured LLM model and communicates via MQTT +to receive a response tuple containing text, selection, reward, and terminal status. # Arguments - - `a::T1` - one of Yiem's agent - - `input::T2` - text to be send to virtual wine customer - -# Return - - `response::String` - response of virtual wine customer -# Example -```jldoctest -julia> -``` +- `a::T1`: An agent instance (subtype of `agent`) with `config` containing `externalservice` + and `mqttServerInfo` keys +- `input::T2`: Text to send to the virtual wine customer LLM + +# Returns +- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of + `(response_text, select, reward, isterminal)` where `select` may be `Nothing` + +# Notes +- Requires `a.config["externalservice"]["virtualWineCustomer_1"]` with `llminfo` and `mqtttopic`. +- Requires `a.config["mqttServerInfo"]` with `broker` and `port`. +- Only supports `llama3instruct` model name (other models throw an error). +- Uses `GeneralUtils.sendReceiveMqttMsg` with a 120-second timeout. # TODO - - [] update docstring - - [] add reccommend() to compare wine - -# Signature +- Add `recommend()` to compare wines """ function virtualWineUserRecommendbox(a::T1, input )::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:agent} @@ -73,27 +75,36 @@ end -""" Chatbox for chatting with virtual wine customer. +""" +Chatbox for conversing with a virtual wine customer AI. + +Formats the chat history with a system prompt, sends it via MQTT to a text2text instruct +LLM service, and parses the JSON response. Retries up to 5 times on failure. # Arguments - - `a::T1` - one of Yiem's agent - - `input::T2` - text to be send to virtual wine customer - -# Return - - `response::String` - response of virtual wine customer -# Example +- `config::T1`: Configuration dictionary (subtype of `AbstractDict`) containing: + - `externalservice["text2text"]["mqtttopic"]`: MQTT topic for the LLM service + - `mqttServerInfo["broker"]`: MQTT broker address + - `mqttServerInfo["port"]`: MQTT broker port +- `input::T2`: Current sommelier message text (subtype of `AbstractString`) +- `virtualCustomerChatHistory`: Chat history vector of dictionaries with `"name"` and `"text"` keys + +# Returns +- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of + `(text, select, reward, isterminal)` representing the virtual customer's response + +# Notes +- The system prompt defines the virtual customer's persona and response format. +- Chat history role names are transformed: "user" → "you", "assistant" → "sommelier". +- Uses `jsoncorrection` to fix malformed LLM JSON responses. +- Retries up to 5 times on error before throwing. +- Uses `formatLLMtext` with `"llama3instruct"` format. + +# Examples ```jldoctest -julia> +julia> result = YiemAgent.virtualWineUserChatbox(config, sommelier_msg, history) +("I'd like something under $50", nothing, 0, false) ``` - -# TODO - - [] update docs - - [x] write a prompt for virtual customer - -# Signature """ function virtualWineUserChatbox(config::T1, input::T2, virtualCustomerChatHistory )::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:AbstractDict, T2<:AbstractString} @@ -265,24 +276,36 @@ pushfirst!(virtualCustomerChatHistory, Dict("name"=> "system", "text"=> systemms error("virtualWineUserChatbox failed to get a response") end -""" Search wine in stock. +""" +Search for wines in stock. + +Executes a wine search via SQL (either through SQLLLM or direct query), optionally fetching +and base64-encoding bottle images for each result. # Arguments - - `a::T1` - one of ChatAgent's agent. - - `thoughtdict::AbstractDict` -# Return - A JSON string of available wine +- `a::T`: An agent instance (subtype of `agent`) with context containing `executeSQL`, + `pg_conn_str`, and `agentconfig` +- `thoughtdict::AbstractDict`: A dictionary containing `action_input` (the search query string) -# Example +# Keyword Arguments +- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query instead of direct SQL generation + +# Returns +- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}`: A named tuple with: + - `thoughtdict`: The input thoughtdict with `action_result` populated + - `result_raw`: Vector of wine dictionaries with image data, or `nothing` + +# Notes +- When `useSQLLLM=false`, performs hard SQL filtering followed by vector search. +- Fetches bottle images from `http://192.168.88.106:8080/` and encodes as base64. +- Uses `wine_search_term_classification` to extract search conditions. +- Requires PostgreSQL connection info from `a.context.agentconfig["externalservice"]["sommpanion_db"]`. + +# Examples ```jldoctest -julia> using ChatAgent -julia> agent = YiemAgent.sommelier(...) -julia> thoughtdict = - OrderedDict{String, Any}( - "plan" => "The user is asking a very specific question about a wine (Brunello di Montalcino from Tenuta CastelGiocondo). Although the policy suggests gathering budget, wine type, and occasion, the user has provided enough specific information (name, region, producer) to attempt a direct search in the database. I will use the SEARCH_WINE_DATABASE action to check if this specific wine is in our inventory.", - "action_name" => "SEARCH_WINE_DATABASE", - "action_input" => "Brunello di Montalcino from Tenuta CastelGiocondo") +julia> thoughtdict = OrderedDict("action_input" => "red wine under 50"); +julia> result = YiemAgent.search_wine_database!(agent, thoughtdict) +(thoughtdict=OrderedDict{String, Any}(...), result_raw=[Dict(...), ...]) ``` """ function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false @@ -371,6 +394,39 @@ function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool= end +""" +Generate an SQL query from a natural language search term. + +Uses an LLM to generate SQL based on the database schema relevant to the search term, +with validation and retry logic. + +# Arguments +- `a::T`: An agent instance (subtype of `agent`) with context containing: + - `find_related_tables_for_user_question`: Function to find relevant database tables + - `pg_conn_str`: PostgreSQL connection string + - `text2textInstructLLM`: LLM function for text generation +- `searchterm::String`: Natural language search term to convert to SQL + +# Keyword Arguments +- `maxattempt::Int=10`: Maximum number of attempts to get a valid SQL response from the LLM + +# Returns +- `String`: A valid SQL query string + +# Notes +- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model for SQL generation. +- Dynamically fetches only relevant table schemas for the search term. +- Validates LLM response contains required keys: "plan", "action_name", "action_input". +- `action_name` must be "RUNSQL"; `action_input` must not contain "RUNSQL". +- Strips triple backticks and extracts SQL from fenced code blocks. +- Throws on failure after `maxattempt` retries. + +# Examples +```jldoctest +julia> sql = YiemAgent.generatesql(agent, "red wine under 50") +"SELECT ... WHERE w.wine_type = 'red' AND rw.price < 50;" +``` +""" function generatesql(a::T, searchterm::String, ; maxattempt=10 )::String where {T<:agent} @@ -655,6 +711,40 @@ julia> thoughtdict = ``` julia> predefined_wine_search_sql(agent, thoughtdict["action_input"]) """ +""" +Classify a wine search term into hard SQL conditions and vector search words. + +Uses an LLM to extract database column conditions from a natural language search term, +then classifies them into hard conditions (for SQL WHERE clauses) and vector search +entries (for approximate matching). + +# Arguments +- `a::T`: An agent instance (subtype of `agent`) with context containing: + - `find_related_tables_for_user_question`: Function to find relevant tables + - `pg_conn_str`: PostgreSQL connection string + - `text2textInstructLLM`: LLM function for text generation +- `searchterm::String`: Natural language search term to classify + +# Keyword Arguments +- `maxattempt::Int=10`: Maximum number of attempts to get a valid classification from the LLM + +# Returns +- `NamedTuple{(:hard_conditions, :vector_search), Tuple{Vector{JSON.Object{String, Any}}, Vector{JSON.Object{String, Any}}}}`: + - `hard_conditions`: Entries with standard operators (=, <>, !=, >, <, >=, <=) for SQL WHERE clauses + - `vector_search`: Entries with non-standard operators (LIKE, IN, IS NULL, etc.) for vector search + +# Notes +- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with JSON schema response format. +- Applies fuzzy correction for "fuzzy_correction" bucket columns via `resolve_entity`. +- Uses `classify_column` to determine the column type bucket. +- Uses `harvest_entity_catalog` and `resolve_entity` (threshold=0.9) for fuzzy matching. + +# Examples +```jldoctest +julia> result = YiemAgent.wine_search_term_classification(agent, "dry red wine under 30") +(hard_conditions=[...], vector_search=[...]) +``` +""" function wine_search_term_classification(a::T, searchterm::String, ; maxattempt=10 ) where {T<:agent} @@ -857,6 +947,35 @@ function wine_search_term_classification(a::T, searchterm::String, error("SQLLLM DecisionMaker() failed to generate a thought \n", response) end +""" +Build a SQL query from pre-classified wine search conditions. + +Constructs a JOIN query across `wine`, `retailer_wine`, and `retailer` tables with +dynamic WHERE clauses based on the provided conditions. + +# Arguments +- `conditions::Vector{JSON.Object{String, Any}}`: Vector of condition objects, each containing: + - `table_name`: One of "wine", "retailer_wine" (other tables are skipped) + - `column_name`: The column to filter on + - `operator`: SQL comparison operator (=, <>, !=, >, <, >=, <=) + - `value`: The value to compare against (number or string) + +# Returns +- `String`: A complete SQL query with WHERE clause + +# Notes +- Supports table aliases: "wine" → "w", "retailer_wine" → "rw" +- Automatically handles numeric vs string value types in SQL formatting +- Strings are single-quote escaped (replaces "'" with "''") +- Returns a query with no WHERE clause if conditions vector is empty + +# Examples +```jldoctest +julia> cond = [JSON.Object{String, Any}("table_name"=>"wine", "column_name"=>"wine_type", "operator"=>"=", "value"=>"red")]; +julia> YiemAgent.predefined_wine_search_sql(cond) +"SELECT ... FROM wine AS w JOIN ... WHERE w.wine_type = 'red';" +``` +""" function predefined_wine_search_sql(conditions::Vector{JSON.Object{String, Any}})::String # 1. Base SQL structure base_query = @@ -934,6 +1053,36 @@ JOIN retailer AS r ON rw.retailer_id = r.retailer_id return string(base_query, where_sql, ";") end +""" +Execute a SQL query against the database and return formatted results. + +Adds `ORDER BY RANDOM() LIMIT 2` for non-LIMITed queries, removes `DISTINCT`, and returns +either a formatted string result or the DataFrame. + +# Arguments +- `executeSQL::Function`: A function that executes SQL and returns results (e.g., PostgreSQL connection) +- `sql::T`: The SQL query string to execute (subtype of `AbstractString`) + +# Returns +- `NamedTuple{(:result_str, :result_raw, :success, :errormsg)}`: A named tuple with: + - `result_str::Union{String, Nothing}`: Formatted string representation of results + - `result_raw::Union{DataFrame, Nothing}`: The DataFrame result, or `nothing` + - `success::Bool`: Whether the query executed successfully + - `errormsg::Union{String, Nothing}`: Error message if failed, or `nothing` + +# Notes +- Removes `DISTINCT` keyword before execution (incompatible with `RANDOM()`) +- Appends `ORDER BY RANDOM() LIMIT 2` if query doesn't have `LIMIT` and ends with `;` +- Returns "No records found" message if zero rows +- Returns column count warning if more than 30 columns +- Randomly samples 2 rows if result has more than 2 rows + +# Examples +```jldoctest +julia> result = YiemAgent.SQLexecution(execute_fn, "SELECT * FROM wine;") +(result_str="...", result_raw=DataFrame(...), success=true, errormsg=nothing) +``` +""" function SQLexecution(executeSQL::Function, sql::T )::NamedTuple where {T<:AbstractString} @@ -984,6 +1133,26 @@ function SQLexecution(executeSQL::Function, sql::T end end +""" +DEPRECATED: Search for wines in stock (legacy implementation). + +Use `search_wine_database!` instead. This function uses the older approach with +`extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. + +# Arguments +- `a::T`: An agent instance (subtype of `agent`) +- `thoughtdict::AbstractDict`: Dictionary containing `action_input` (search query) + +# Keyword Arguments +- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query + +# Returns +- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}` + +# Notes +- DEPRECATED: Use `search_wine_database!` for the current implementation. +- Calls `extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. +""" function DEPRECIATED_search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} @@ -1044,16 +1213,38 @@ function DEPRECIATED_search_wine_database!(a::T, thoughtdict::AbstractDict; useS end """ +Extract wine attributes from a user's search query. + +Uses an LLM to parse natural language input into structured wine attributes including +name, winery, vintage, country, type, grape, price range, occasion, and food pairing. # Arguments - - `v::Integer` - dummy variable - -# Return +- `a::T1`: An agent instance (subtype of `agent`) with context containing: + - `text2textInstructLLM`: LLM function for text generation + - `pg_conn_str`: PostgreSQL connection string + - `id`: Agent identifier +- `input::T2`: User's search query string (subtype of `AbstractString`) -# Example +# Keyword Arguments +- `maxattempt::Int=10`: Maximum number of attempts to get a valid response from the LLM + +# Returns +- `String`: Comma-separated list of extracted attributes in the format `"key: value, key: value"` + Attributes with "N/A", empty, or "none" values are excluded. + +# Notes +- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. +- Extracts: wine_name, winery, vintage, country, wine_type, grape_varietal, tasting_notes, + wine_price_min, wine_price_max, occasion, food_to_be_paired_with_wine +- Validates response contains all required keys via `checkAgentResponse_JSON`. +- Applies fuzzy entity resolution via `harvest_entity_catalog` and `resolve_entity` (threshold=0.9). +- Strips "(some comment)" patterns from values. +- Removes keys: thought, tasting_notes, occasion, food_to_be_paired_with_wine, vintage from final output. + +# Examples ```jldoctest -julia> +julia> YiemAgent.extractWineAttributes_1(agent, "red wine from Napa under 50") +"wine_name:N/A, winery:N/A, vintage:N/A, country:United States, wine_type:red, grape_varietal:N/A, wine_price_min:0, wine_price_max:50" ``` """ function extractWineAttributes_1(a::T1, input::T2; maxattempt=10 @@ -1225,8 +1416,40 @@ function extractWineAttributes_1(a::T1, input::T2; maxattempt=10 end """ - - TODO "French dry white wines with medium bod" the LLM does not recognize sweetness. use LLM self questioning to solve. - - TODO French Syrah, Viognier, under 100. LLM extract intensiry of 3-5. why? +Extract wine intensity, sweetness, tannin, and acidity attributes from a query. + +Uses an LLM with a conversion table to map descriptive words (e.g., "medium-bodied", "low acidity") +to integer ranges on a 1-5 scale. + +# Arguments +- `a::T1`: An agent instance (subtype of `agent`) with context containing: + - `text2textInstructLLM`: LLM function for text generation + - `id`: Agent identifier +- `input::T2`: User's query string containing descriptive wine preferences + (subtype of `AbstractString`) + +# Returns +- `String`: Comma-separated list of extracted attributes in the format + `"key: value, key: value"`. Only includes numeric values or non-N/A strings. + +# Notes +- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. +- Extracts: sweetness, acidity, tannin, intensity (each with min/max values). +- Applies `remove_french_accents` to the LLM response. +- Extracts thinking via `extractthink` before JSON parsing. +- Validates response contains all required keys via `checkAgentResponse_JSON`. +- Removes keyword fields (sweetness_keyword, acidity_keyword, etc.) from final output. +- Only includes values that are numbers or non-"N/A" strings. + +# TODO +- "French dry white wines with medium bod" — the LLM does not recognize sweetness. Use LLM self-questioning to solve. +- French Syrah, Viognier, under 100 — LLM extracts intensity of 3-5. Investigate why. + +# Examples +```jldoctest +julia> YiemAgent.extractWineAttributes_2(agent, "medium-bodied, low acidity, medium tannin") +"acidity_min:1, acidity_max:2, tannin_min:3, tannin_max:4, intensity_min:3, intensity_max:4" +``` """ function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<:AbstractString} @@ -1423,12 +1646,67 @@ end +""" +Get a simplified DDL schema for a PostgreSQL table with sample values. + +Establishes a new connection and delegates to the connection-based overload. + +# Arguments +- `pg_conn_str::String`: PostgreSQL connection string +- `table_name::String`: Name of the table to get schema for + +# Keyword Arguments +- `schema_name::String="public"`: PostgreSQL schema name + +# Returns +- `String`: Formatted DDL schema string with sample values + +# Notes +- Delegates to `get_db_table_schema_simple_with_samples(conn, table_name, ...)` after creating + a new `LibPQ.Connection`. + +# Examples +```jldoctest +julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn_str, "wine") +"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" +``` +""" function get_db_table_schema_simple_with_samples(pg_conn_str::String, table_name::String; schema_name::String="public")::String conn = LibPQ.Connection(pg_conn_str) return get_db_table_schema_simple_with_samples(conn, table_name; schema_name=schema_name) end +""" +Get a simplified DDL schema for a PostgreSQL table with sample values. + +Queries the PostgreSQL catalog for column metadata, fetches sample values, and builds +a DDL string with inline comments for each column. + +# Arguments +- `conn`: An active PostgreSQL connection (e.g., `LibPQ.Connection`) +- `table_name::String`: Name of the table to get schema for + +# Keyword Arguments +- `schema_name::String="public"`: PostgreSQL schema name +- `sample_count::Int=3`: Number of non-null sample values to fetch per column + +# Returns +- `String`: Formatted DDL schema string in the style of `CREATE TABLE` statements + with sample values as inline comments + +# Notes +- Queries `pg_attribute`, `pg_class`, `pg_namespace`, `pg_attrdef`, and `pg_constraint` + for column metadata, defaults, and constraints. +- Fetches up to `sample_count` non-null values per column via `json_agg`. +- Throws an error if the table is not found. + +# Examples +```jldoctest +julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn, "wine") +"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" +``` +""" function get_db_table_schema_simple_with_samples(conn, table_name::String; schema_name::String="public", sample_count::Int=3)::String # 1. SQL query for catalog metadata meta_sql = """ diff --git a/src/type.jl b/src/type.jl index 9b5cb62..6e57efc 100644 --- a/src/type.jl +++ b/src/type.jl @@ -194,6 +194,21 @@ end # Tool types # ============================================================================ +""" +A tool available to the agent. + +# Arguments +- `name::String`: Tool identifier +- `label::String`: Human-readable tool name +- `description::String`: What the tool does +- `parameters::TParameters`: Tool parameters schema (JSON schema) +- `execute::Function`: Tool execution function +- `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback +- `executionMode::Union{toolExecutionMode, Nothing}`: Override: run tool calls sequentially or in parallel + +# Returns +- A new `agentTool` instance +""" struct agentTool{TParameters, TDetails} # A tool available to the agent name::String # Tool identifier label::String # Human-readable tool name @@ -209,6 +224,17 @@ end # Agent context # ============================================================================ +""" +Snapshot of the agent's conversation context. + +# Arguments +- `systemPrompt::String`: System prompt for the agent +- `messages::Vector{agentMessage}`: Conversation messages +- `tools::Union{Vector{agentTool}, Nothing}`: Available tools + +# Returns +- A new `agentContext` instance +""" struct agentContext # Snapshot of the agent's conversation context systemPrompt::String # System prompt for the agent messages::Vector{agentMessage} # Conversation messages @@ -266,9 +292,6 @@ function agentState( ) end -# ============================================================================ -# Tool call types -# ============================================================================ struct toolCall # A tool invocation from the LLM type::String # Always "function" @@ -278,20 +301,25 @@ struct toolCall # A tool invocation from the LLM end -# ============================================================================ -# Next turn context -# ============================================================================ +""" +Context for preparing the next conversation turn. -struct nextTurnContext # Context for preparing the next conversation turn +# Arguments +- `message::assistantMessage`: The assistant's message that just completed +- `toolResults::Vector{toolResultMessage}`: Tool results from this turn +- `context::agentContext`: Current conversation context +- `newMessages::Vector{agentMessage}`: Messages to append to the context + +# Returns +- A new `prepareNextTurnContext` instance +""" +struct prepareNextTurnContext # Context for preparing the next conversation turn message::assistantMessage # The assistant's message that just completed toolResults::Vector{toolResultMessage} # Tool results from this turn context::agentContext # Current conversation context newMessages::Vector{agentMessage} # Messages to append to the context end -# ============================================================================ -# llmModel types -# ============================================================================ struct modelCost # Model pricing per 1M tokens input::Float64 # Price per 1M input tokens diff --git a/src/util.jl b/src/util.jl index e6e9f69..9460d97 100644 --- a/src/util.jl +++ b/src/util.jl @@ -10,47 +10,26 @@ using ..type # ---------------------------------------------- 100 --------------------------------------------- # -""" Clear agent chat history. +""" +Clear agent chat history. + +Empties the conversation history, short-term memory, events log, and chatbox. # Arguments - - `a::agent` - an agent +- `a::T`: An agent instance (subtype of `agent`) -# Return - - nothing +# Returns +- `nothing` -# Example +# Notes +- Does not clear long-term memory; use `[PENDING] clear memory` when implemented. + +# Examples ```jldoctest -julia> using YiemAgent, MQTTClient, GeneralUtils -julia> client, connection = MakeConnection("test.mosquitto.org", 1883) -julia> connect(client, connection) -julia> msgMeta = GeneralUtils.generate_msgMeta("testtopic") -julia> agentConfig = Dict( - "receiveprompt"=>Dict( - "mqtttopic"=> "testtopic/receive", - ), - "receiveinternal"=>Dict( - "mqtttopic"=> "testtopic/internal", - ), - "text2text"=>Dict( - "mqtttopic"=> "testtopic/text2text", - ), - ) -julia> a = YiemAgent.sommelier( - client, - msgMeta, - agentConfig, - ) -julia> YiemAgent.addNewMessage(a, "user", "hello") -julia> YiemAgent.clearhistory(a) +julia> YiemAgent.clearhistory(agent) ``` - -# TODO - - [PENDING] clear memory - -# Signature """ -function clearhistory(a::T) where {T<:agent} +function clearhistory(a::T) where {T<:agent} empty!(a.chathistory) empty!(a.memory["shortmem"]) empty!(a.memory["events"]) @@ -58,40 +37,29 @@ function clearhistory(a::T) where {T<:agent} end -""" Add new message to agent. +""" +Add a new message to the agent's conversation history. - messages => Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Describe this image for me"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data_uri) - ) - ] - ) +Automatically summarizes the oldest messages if the history exceeds `maximumMsg`. - Arguments\n - ----- - a::agent - an agent - role::String - message sender role i.e. system, user or assistant - text::String - message text +# Arguments +- `a::T1`: An agent instance (subtype of `agent`) +- `name::String`: Message sender role (e.g. "system", "user", "assistant") +- `userinput::T2`: Message dictionary to append (must contain "name" and "text" keys) - Return\n - ----- - nothing +# Keyword Arguments +- `maximumMsg::Integer=30`: Maximum number of messages before summarization kicks in - Example\n - ----- - ```jldoctest +# Returns +- `nothing` - ``` +# Notes +- When history length exceeds `maximumMsg`, the oldest messages are summarized automatically. - Signature\n - ----- +# Examples +```jldoctest +julia> YiemAgent.addNewMessage(agent, "user", Dict("name" => "user", "text" => "hello")) +``` """ function addNewMessage(a::T1, name::String, userinput::T2; maximumMsg::Integer=30) where {T1<:agent, T2<:AbstractDict} @@ -168,6 +136,23 @@ function chatHistoryToText(vecd::Vector; withkey=true, range=nothing)::String 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 = "" @@ -189,34 +174,30 @@ end -""" Create a dictionary representing an event with optional details. +""" +Create a dictionary representing an event with optional details. -# Arguments - - `event_description::Union{String, Nothing}` - A description of the event - - `timestamp::Union{DateTime, Nothing}` - The time when the event occurred - - `subject::Union{String, Nothing}` - The subject or entity associated with the event - - `thought::Union{AbstractDict, Nothing}` - Any associated thoughts or metadata - - `action_name::Union{String, Nothing}` - The name of the action performed (e.g., "CHAT", "CHECKINVENTORY") - - `action_input::Union{String, Nothing}` - Input or parameters for the action - - `location::Union{String, Nothing}` - Where the event took place - - `equipment_used::Union{String, Nothing}` - Equipment involved in the event - - `material_used::Union{String, Nothing}` - Materials used during the event - - `outcome::Union{String, Nothing}` - The result or consequence of the event after action execution - - `note::Union{String, Nothing}` - Additional notes or comments +# Keyword Arguments +- `event_description::Union{String, Nothing}`: A description of the event +- `timestamp::Union{DateTime, Nothing}`: The time when the event occurred +- `subject::Union{String, Nothing}`: The subject or entity associated with the event +- `thought::Union{AbstractDict, Nothing}`: Any associated thoughts or metadata +- `action_name::Union{String, Nothing}`: The name of the action performed (e.g., "CHAT", "CHECKINVENTORY") +- `action_input::Union{String, Nothing}`: Input or parameters for the action +- `location::Union{String, Nothing}`: Where the event took place +- `equipment_used::Union{String, Nothing}`: Equipment involved in the event +- `material_used::Union{String, Nothing}`: Materials used during the event +- `observation::Union{String, Nothing}`: Observation of the event +- `note::Union{String, Nothing}`: Additional notes or comments # Returns - A dictionary with event details as symbol-keyed key-value pairs +- A `Dict{String, Any}` with event details as string-keyed key-value pairs + +# Examples +```jldoctest +julia> YiemAgent.eventdict(action_name="CHAT", action_input="hello") +Dict{String, Any} with 11 entries: ... +``` """ function eventdict(; event_description::Union{String, Nothing}=nothing, @@ -250,31 +231,31 @@ function eventdict(; end -""" Create a formatted timeline string from a sequence of events. +""" +Create a formatted timeline string from a sequence of events. # Arguments - - `events::T1` - Vector of event dictionaries containing subject, action_input and optional outcome fields - Each event dictionary should have the following keys: - - :subject - The subject or entity performing the action - - :action_input - The action or input performed by the subject - - :observation - (Optional) The result or outcome of the action +- `events::T1`: Vector of event dictionaries. Each must have `action_name` and `action_input` keys, + and optionally `subject` and `observation` keys. + +# Keyword Arguments +- `eventindex::Union{UnitRange, Nothing}=nothing`: Optional range of event indices to include. + If `nothing`, all events are included. # Returns - - `timeline::String` - A formatted string representing the events with their subjects, actions, and optional outcomes - Format: "{index}) {subject}> {action_input} {outcome}\n" for each event - -# Example - -events = [ - Dict("subject" => "User", "action_input" => "Hello", "observation" => nothing), - Dict("subject" => "Assistant", "action_input" => "Hi there!", "observation" => "with a smile") -] -timeline = createTimeline(events) -# 1) User> Hello -# 2) Assistant> Hi there! with a smile +- `timeline::String`: A formatted string where each event appears on its own line in the format: + `"Event_{index} {subject}> action_name: {action_name}, action_input: {action_input}"` + If `observation` is present, it is appended. +# Examples +```jldoctest +julia> events = [ + Dict("subject" => "User", "action_input" => "Hello", "action_name" => "CHAT", "observation" => nothing), + Dict("subject" => "Assistant", "action_input" => "Hi there!", "action_name" => "CHAT", "observation" => "with a smile") + ]; +julia> YiemAgent.createTimeline(events) +"Event_1 User> action_name: CHAT, action_input: Hello\\nEvent_2 Assistant> action_name: CHAT, action_input: Hi there!\\n" +``` """ function createTimeline(events::T1; eventindex::Union{UnitRange, Nothing}=nothing ) where {T1<:AbstractVector} @@ -308,6 +289,29 @@ function createTimeline(events::T1; eventindex::Union{UnitRange, Nothing}=nothin return timeline end +""" +Create a formatted event log from a sequence of events. + +# Arguments +- `events::T1`: Vector of event dictionaries. Each must have `subject`, `action_name`, `action_input`, + and optionally `observation` keys. + +# Keyword Arguments +- `index::Union{UnitRange, Nothing}=nothing`: Optional range of event indices to include. + If `nothing`, all events are included. + +# Returns +- A `Vector{Dict{String, String}}` where each dictionary has `"name"` (from event subject) and + `"text"` (formatted action description) keys. + +# Examples +```jldoctest +julia> events = [Dict("subject" => "User", "action_name" => "CHAT", "action_input" => "hello", "observation" => nothing)]; +julia> log = YiemAgent.createEventsLog(events); +julia> log[1]["name"] +"User" +``` +""" function createEventsLog(events::T1; index::Union{UnitRange, Nothing}=nothing ) where {T1<:AbstractVector} # Initialize empty log array @@ -347,6 +351,27 @@ function createEventsLog(events::T1; index::Union{UnitRange, Nothing}=nothing end +""" +Create a formatted chat log from a sequence of chat entries. + +# Arguments +- `chatdict::T1`: Vector of chat entry dictionaries. Each must have `"name"` and `"text"` keys. + +# Keyword Arguments +- `index::Union{UnitRange, Nothing}=nothing`: Optional range of entry indices to include. + If `nothing`, all entries are included. + +# Returns +- A `Vector{Dict{String, String}}` where each dictionary has `"name"` and `"text"` keys + copied from the corresponding input entry. + +# Examples +```jldoctest +julia> chats = [Dict("name" => "user", "text" => "hello"), Dict("name" => "assistant", "text" => "hi")]; +julia> YiemAgent.createChatLog(chats)[1]["name"] +"user" +``` +""" function createChatLog(chatdict::T1; index::Union{UnitRange, Nothing}=nothing ) where {T1<:AbstractVector} # Initialize empty log array @@ -373,6 +398,29 @@ function createChatLog(chatdict::T1; index::Union{UnitRange, Nothing}=nothing end +""" +Check if an agent's text response contains all required header keywords. + +Validates that the response includes all required keywords without duplications. + +# Arguments +- `response::String`: The agent's text response to validate +- `requiredHeader::T`: Array of required keyword strings (subtype of `Array{String}`) + +# Returns +- `Tuple{Bool, Union{String, Nothing}}`: A two-element tuple where: + - First element: `true` if all required keywords are present and not duplicated, `false` otherwise + - Second element: An error description string if validation failed, or `nothing` if passed + +# Notes +- Uses `GeneralUtils.detectKeywordVariation` for flexible keyword matching. + +# Examples +```jldoctest +julia> ispass, err = YiemAgent.checkAgentResponse_text("hello world", ["hello"]) +(true, nothing) +``` +""" function checkAgentResponse_text(response::String, requiredHeader::T )::Tuple where {T<:Array{String}} detected_kw = GeneralUtils.detectKeywordVariation(requiredHeader, response) -- 2.52.0 From 937d52053fde4954c613c3571744f2065f7fef7c Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 12:13:22 +0700 Subject: [PATCH 012/112] update --- src/agentCore.jl | 48 +++++++++++++++++++++++++----------------------- src/api.jl | 20 ++++++++++---------- src/type.jl | 23 +++++++++++------------ 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index c5cde25..2a55205 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -12,9 +12,9 @@ using ..type, ..util, ..llmfunction """ Private agent loop. Runs in a background `@spawn` task. -Waits on `input_ch` and `followUpQueue`, processing whichever has a message first. +Waits on `inputChannel` and `followUpChannel`, processing whichever has a message first. On each iteration, dispatches the message through `_process_message` and sends the result -to `output_ch`. Exits on `:shutdown` signal. +to `outputChannel`. Exits on `:shutdown` signal. # Arguments - `agent::yiemAgent`: The agent whose loop to run @@ -25,22 +25,26 @@ to `output_ch`. Exits on `:shutdown` signal. # Notes - This function is automatically spawned as a background task when a `yiemAgent` is created. - On any error, logs the error with `@error` and exits the loop. -- Message priority: `input_ch` messages are checked before `followUpQueue` messages. +- Message priority: `inputChannel` messages are checked before `followUpChannel` messages. # Examples ```jldoctest julia> # Called automatically by yiemAgent constructor ``` """ -function _agent_loop(agent::yiemAgent) #WORKING +function _agent_loop(agent::yiemAgent) try while true # Wait on either channel — the one with a message fires first # Wait on either channel — the one with a message is taken first msg = nothing while msg === nothing - if isready(agent.input_ch) - msg = take!(agent.input_ch) + if isready(agent.inputChannel) + msg = take!(agent.inputChannel) + + #TODO convert raw user msg to userMessage type + + #TODO add userMessage to agent._state.messages else yield() end @@ -52,20 +56,19 @@ function _agent_loop(agent::yiemAgent) #WORKING break end - #TODO convert raw user msg to userMessage type - - #TODO add userMessage to agent._state.messages - - - if isready(agent.followUpQueue) - msg = take!(agent.followUpQueue) - end - - # @spawn. Dispatch message through the processing pipeline. + # Dispatch message through the processing pipeline result = _process_message(agent, msg) - # Send response to user - put!(agent.output_ch, result) + + + # check followUp message. if there are, add them all to agent.inputChannel + + + + + # Send response to user if no user message in both agent.inputChannel and agent.followUpChannel, + # output the result + put!(agent.outputChannel, result) end catch e # On any error, send error response and exit the loop @@ -73,6 +76,7 @@ function _agent_loop(agent::yiemAgent) #WORKING end end + """ Process a single message through the agent pipeline. @@ -81,7 +85,7 @@ should be implemented. Currently a placeholder that echoes back the received mes # Arguments - `agent::yiemAgent`: The agent processing the message -- `msg`: The message to process (from `input_ch` or `followUpQueue`) +- `msg`: The message to process (from `inputChannel` or `followUpChannel`) # Returns - An `assistantMessage` instance with the processed response @@ -102,14 +106,12 @@ julia> # Currently returns a placeholder echo response """ function _process_message(agent::yiemAgent, msg) # WORKING Replace with actual processing logic - # check steering message + + while (# ) - - - # 1. call agent. # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM # 3. If preprocessMessages is set, call agent.preprocessMessages(...) # 4. Call the LLM (blocking — the task waits here) diff --git a/src/api.jl b/src/api.jl index f8769bb..9412301 100644 --- a/src/api.jl +++ b/src/api.jl @@ -15,7 +15,7 @@ using ..type, ..util, ..llmfunction Send a message to the agent's input channel. Blocks if the input channel buffer is full (capacity 16 by default). -The agent processes messages from `input_ch` in the background task. +The agent processes messages from `inputChannel` in the background task. # Arguments - `agent::yiemAgent`: The agent instance to send a message to @@ -35,7 +35,7 @@ yiemAgent(...) ``` """ function run_agent(agent::yiemAgent, msg) - put!(agent.input_ch, msg) + put!(agent.inputChannel, msg) return agent end @@ -60,13 +60,13 @@ assistantMessage(...) ``` """ function take_response(agent::yiemAgent) - return take!(agent.output_ch) + return take!(agent.outputChannel) end """ Send a follow-up message while the agent is still processing. -Follow-up messages are queued and processed after all `input_ch` messages +Follow-up messages are queued and processed after all `inputChannel` messages and before any tool call results are sent. # Arguments @@ -88,7 +88,7 @@ yiemAgent(...) ``` """ function follow_up(agent::yiemAgent, msg) - put!(agent.followUpQueue, msg) + put!(agent.followUpChannel, msg) return agent end @@ -96,7 +96,7 @@ end Gracefully stop the agent. Sends a `:shutdown` signal to the input channel, waits for the background task to finish, -then closes all channels (`input_ch`, `output_ch`, `followUpQueue`). +then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`). # Arguments - `agent::yiemAgent`: The agent instance to stop @@ -115,7 +115,7 @@ julia> stop_agent(agent) ``` """ function stop_agent(agent::yiemAgent) - put!(agent.input_ch, :shutdown) + put!(agent.inputChannel, :shutdown) try fetch(agent._task) catch e @@ -123,9 +123,9 @@ function stop_agent(agent::yiemAgent) rethrow(e) end end - close(agent.input_ch) - close(agent.output_ch) - close(agent.followUpQueue) + close(agent.inputChannel) + close(agent.outputChannel) + close(agent.followUpChannel) return nothing end diff --git a/src/type.jl b/src/type.jl index 6e57efc..1856a56 100644 --- a/src/type.jl +++ b/src/type.jl @@ -353,18 +353,17 @@ docstring mutable struct yiemAgent <: agent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) - input_ch::Channel # user sends prompt message to agent. + inputChannel::Channel # user sends prompt message to agent. # if agent is idle, it process user message right away. # if agent is running, it process user message after # the current tool call finished. - followUpQueue::Channel # Messages queued via follow_up() during agent is - # running. After the agent loop process all input_ch - # and the agent isn't using tool call, it processes - # followUp messages + followUpChannel::Channel # Buffers messages the user sends while the agent is busy. + # Processed after all inputChannel messages are handled + # and the agent is idle (not using a tool call). - output_ch::Channel # agent sends response message to user after processing - # all user messages in input_ch and all followUp messages. + outputChannel::Channel # agent sends response message to user after processing + # all user messages in inputChannel and all followUp messages. _task::Union{Task, Nothing} # Background task running the agent loop @@ -383,7 +382,7 @@ end Create a new yiemAgent instance with a background loop task. Spawns a background `@spawn` task that runs the agent loop, listening -on `input_ch` and `followUpQueue` channels concurrently. +on `inputChannel` and `followUpChannel` channels concurrently. # Keyword Arguments - `systemPrompt::String`: System prompt for the agent @@ -425,16 +424,16 @@ function yiemAgent( toolExecution=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) - input_ch = Channel(16) + inputChannel = Channel(16) followUp = Channel(32) - output_ch = Channel(16) + outputChannel = Channel(16) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( agentState(systemPrompt, model, tools, messages), - input_ch, + inputChannel, followUp, - output_ch, + outputChannel, nothing, # placeholder — replaced below formatMsgForLLM, preprocessMessages, -- 2.52.0 From 52eaeab5fc02ecb24ba191aef4e7515e2788563e Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 14:51:53 +0700 Subject: [PATCH 013/112] update --- src/agentCore.jl | 41 +++++++++++++++++++++++------------------ src/type.jl | 1 + 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 2a55205..2355c73 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -35,18 +35,19 @@ julia> # Called automatically by yiemAgent constructor function _agent_loop(agent::yiemAgent) try while true - # Wait on either channel — the one with a message fires first - # Wait on either channel — the one with a message is taken first msg = nothing while msg === nothing - if isready(agent.inputChannel) - msg = take!(agent.inputChannel) - - #TODO convert raw user msg to userMessage type - - #TODO add userMessage to agent._state.messages - else + if isready(agent.inputChannel) && agent._state.activeRun == false + # allow _process_message() to run yield() + elseif isready(agent.inputChannel) && agent._state.activeRun == true + msg = fetch!(agent.inputChannel) + # Check for shutdown signal + if msg === :shutdown + msg = take!(agent.inputChannel) + end + else + error("undefined condition: isready(inputChannel)=$(isready(agent.inputChannel)), activeRun=$(agent._state.activeRun), msg=$msg") end end @@ -56,19 +57,23 @@ function _agent_loop(agent::yiemAgent) break end + # make active + agent._state.activeRun = true # Dispatch message through the processing pipeline result = _process_message(agent, msg) - - # check followUp message. if there are, add them all to agent.inputChannel + hasMore = isready(agent.followUpChannel) + while isready(agent.followUpChannel) + followMsg = take!(agent.followUpChannel) + put!(agent.inputChannel, followMsg) + hasMore = true + end - - - - # Send response to user if no user message in both agent.inputChannel and agent.followUpChannel, - # output the result - put!(agent.outputChannel, result) + if !isready(agent.inputChannel) && !hasMore + # no more messages queued — safe to send response + put!(agent.outputChannel, result) + end end catch e # On any error, send error response and exit the loop @@ -107,7 +112,7 @@ julia> # Currently returns a placeholder echo response function _process_message(agent::yiemAgent, msg) # WORKING Replace with actual processing logic - while (# ) + diff --git a/src/type.jl b/src/type.jl index 1856a56..a2d8bc0 100644 --- a/src/type.jl +++ b/src/type.jl @@ -252,6 +252,7 @@ mutable struct agentState # Mutable runtime state of an agen tools::Vector{agentTool} # Available tools messages::Vector{agentMessage} # Conversation messages pendingToolCalls::Vector{String} # Tool call IDs waiting for results + activeRun::Bool # is agent processing user message? errorMessage::Union{String, Nothing} # Last error message end -- 2.52.0 From 2072ebe5541f0c5c53d9c1df2cb022c59fa1bfe4 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 19:45:08 +0700 Subject: [PATCH 014/112] update --- src/agentCore.jl | 108 +++++++++++++++++++++++++++++++++++++---------- src/type.jl | 1 + 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 2355c73..4cf2f7b 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -34,45 +34,109 @@ julia> # Called automatically by yiemAgent constructor """ function _agent_loop(agent::yiemAgent) try + processing_task = nothing + + """ cases: + 1) agent -> idle, user msg -> nothing + typeof(processing_task) == Nothing + agent._state.activeRun -> false + agent.inputChannel -> nothing + agent.followUpChannel -> nothing + + 2) agent -> idle, user msg -> new msg + typeof(processing_task) == Nothing + agent._state.activeRun -> false + agent.inputChannel -> new msg + agent.followUpChannel -> nothing + + 3) agent -> running, user msg -> nothing + typeof(processing_task) == Task, istaskdone(processing_task) -> false + agent._state.activeRun -> true + agent.inputChannel -> nothing + agent.followUpChannel -> nothing + + 4) agent -> running, user msg -> new msg + typeof(processing_task) == Task, istaskdone(processing_task) -> false + agent._state.activeRun -> true + agent.inputChannel -> new msg + agent.followUpChannel -> nothing + + 5) agent -> running, user msg -> nothing, user msg follow up -> new msg + typeof(processing_task) == Task, istaskdone(processing_task) -> false + agent._state.activeRun -> true + agent.inputChannel -> nothing + agent.followUpChannel -> new msg + + 6) agent -> idle, user msg -> nothing + typeof(processing_task) == Task, istaskdone(processing_task) -> true + agent._state.activeRun -> false + agent.inputChannel -> nothing + agent.followUpChannel -> nothing + """ + + while true + result = nothing msg = nothing while msg === nothing - if isready(agent.inputChannel) && agent._state.activeRun == false - # allow _process_message() to run - yield() - elseif isready(agent.inputChannel) && agent._state.activeRun == true - msg = fetch!(agent.inputChannel) - # Check for shutdown signal - if msg === :shutdown - msg = take!(agent.inputChannel) - end + if isready(agent.inputChannel) + + # message will be taken then process in _process_message() + msg = fetch!(agent.inputChannel) else - error("undefined condition: isready(inputChannel)=$(isready(agent.inputChannel)), activeRun=$(agent._state.activeRun), msg=$msg") + yield() end end # Check for shutdown signal if msg === :shutdown + # Drain all remaining messages in the input channel + if isready(agent.inputChannel) + while isready(agent.inputChannel) + _ = take!(agent.inputChannel) + end + end + if isready(agent.followUpChannel) + while isready(agent.followUpChannel) + _ = take!(agent.followUpChannel) + end + end + #TODO make sure every running tools ended properly break end # make active - agent._state.activeRun = true - # Dispatch message through the processing pipeline - result = _process_message(agent, msg) - - # check followUp message. if there are, add them all to agent.inputChannel - hasMore = isready(agent.followUpChannel) - while isready(agent.followUpChannel) - followMsg = take!(agent.followUpChannel) - put!(agent.inputChannel, followMsg) - hasMore = true + if agent._state.activeRun == false + # Dispatch message through the processing pipeline + processing_task = @spawn _process_message(agent, msg) + agent._state.activeRun = true end - if !isready(agent.inputChannel) && !hasMore - # no more messages queued — safe to send response + # during agent runs, check followUp message after _process_message() is done + if typeof(processing_task) == Task && istaskdone(processing_task) == false + # if followUp message available, add them all to agent.inputChannel + if isready(agent.followUpChannel) + while isready(agent.followUpChannel) + followMsg = take!(agent.followUpChannel) + put!(agent.inputChannel, followMsg) + end + end + + continue # continue to process user message in the next loop + elseif typeof(processing_task) == Task && istaskdone(processing_task) == true + # if agent runs is done but followUpChannel has messages, discard all message in it. + # when agent work is done it should not accept follow up msg. + # user should put new message in inputChannel instead + if isready(agent.followUpChannel) + while isready(agent.followUpChannel) + _ = take!(agent.followUpChannel) + end + end + result = fetch(processing_task) put!(agent.outputChannel, result) + agent._state.activeRun = false + processing_task = nothing end end catch e diff --git a/src/type.jl b/src/type.jl index a2d8bc0..dfd5b30 100644 --- a/src/type.jl +++ b/src/type.jl @@ -289,6 +289,7 @@ function agentState( deepcopy(tools), deepcopy(messages), Vector{String}(), + false, nothing, ) end -- 2.52.0 From 96ff9f09241172d3d4e40a8d07eb7494fb38fdf8 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 21:42:42 +0700 Subject: [PATCH 015/112] update --- src/agentCore.jl | 30 ++++++++++++++++++------------ src/type.jl | 42 +++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 4cf2f7b..6cc94d2 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -163,7 +163,7 @@ should be implemented. Currently a placeholder that echoes back the received mes - Implement the full processing pipeline: 1. Add `msg` to `agent._state.messages` 2. Call `agent.formatMsgForLLM(agent._state)` 1 to format for LLM - 3. If `agent.preprocessMessages` is set, call it on the formatted messages + 3. If `agent.preprocessContext` is set, call it on the formatted messages 4. Call the LLM (blocking — the task waits here) 5. If agent has tools, handle tool calls in a loop 6. Build `assistantMessage` and return it @@ -173,19 +173,23 @@ should be implemented. Currently a placeholder that echoes back the received mes julia> # Currently returns a placeholder echo response ``` """ -function _process_message(agent::yiemAgent, msg) - # WORKING Replace with actual processing logic +function _process_message(agent::yiemAgent, msg)::assistantMessage + # WORKING - + # take every messages from agent.inputChannel, convert them into userMessage + # and add them to agent._state.messages + # call agent.preprocessContext() + # Call agent.formatMsgForLLM(agent._state) to format for LLM + # Call the LLM (blocking — the task waits here) - # 2. Call agent.formatMsgForLLM(agent._state) to format for LLM - # 3. If preprocessMessages is set, call agent.preprocessMessages(...) - # 4. Call the LLM (blocking — the task waits here) - # 5. If agent has tools, handle tool calls in a loop - # 6. Build assistantMessage and return it + # call toolArgumentValidation() to make sure soon-to-call tools has valid arguments + + # If agent has tools, handle tool calls in a loop + + # Build assistantMessage and return it # Placeholder: echo back the message as a simple response @warn "TODO: implement _process_message" @@ -200,6 +204,11 @@ function _process_message(agent::yiemAgent, msg) end +function createToolResultMessage()::toolResultMessage + +end + + @@ -264,9 +273,6 @@ end - - - diff --git a/src/type.jl b/src/type.jl index dfd5b30..37ab0fc 100644 --- a/src/type.jl +++ b/src/type.jl @@ -355,24 +355,32 @@ docstring mutable struct yiemAgent <: agent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) - inputChannel::Channel # user sends prompt message to agent. - # if agent is idle, it process user message right away. - # if agent is running, it process user message after - # the current tool call finished. - - followUpChannel::Channel # Buffers messages the user sends while the agent is busy. - # Processed after all inputChannel messages are handled - # and the agent is idle (not using a tool call). - - outputChannel::Channel # agent sends response message to user after processing - # all user messages in inputChannel and all followUp messages. + # user sends prompt message to agent. if agent is idle, it process user message right away. + # if agent is running, it process user message after the current tool call finished. + inputChannel::Channel + + # Buffers messages the user sends while the agent is busy. Processed after all inputChannel + # messages are handled and the agent is idle (not using a tool call). + followUpChannel::Channel + # agent sends response message to user after processing all user messages in inputChannel + # and all followUp messages. + outputChannel::Channel + _task::Union{Task, Nothing} # Background task running the agent loop formatMsgForLLM::Function # Convert agent messages to LLM message format - preprocessMessages ::Union{Function, Nothing} # Preprocess/transform messages before sending to LLM - beforeToolCall::Union{Function, Nothing} # Callback invoked before executing a tool call - afterToolCall::Union{Function, Nothing} # Callback invoked after executing a tool call + + # Preprocess/transform messages and context (modify, filter, prune, inject, reorder, add context) + # for a single LLM call in _process_message()'s loop returns new Vector{agentMessage} + preprocessContext ::Union{Function, Nothing} + + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) + beforeToolCall::Union{Function, Nothing} + + # Callback invoked after executing a tool call to sanitize tools output so the output is ready + # to be converted into toolResults message + afterToolCall::Union{Function, Nothing} prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context sessionId::Union{String, Nothing} # Optional session identifier @@ -392,7 +400,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `tools::Vector{agentTool}`: Available tools (default: empty) - `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) - `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) -- `preprocessMessages::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) +- `preprocessContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) - `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) - `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) - `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) @@ -416,7 +424,7 @@ function yiemAgent( tools::Vector{agentTool}=agentTool[], messages::Vector{agentMessage}=agentMessage[], formatMsgForLLM::Function=defaultformatMsgForLLM, - preprocessMessages::Union{Function, Nothing}=nothing, + preprocessContext::Union{Function, Nothing}=nothing, beforeToolCall::Union{Function, Nothing}=nothing, afterToolCall::Union{Function, Nothing}=nothing, prepareNextTurn::Union{Function, Nothing}=nothing, @@ -438,7 +446,7 @@ function yiemAgent( outputChannel, nothing, # placeholder — replaced below formatMsgForLLM, - preprocessMessages, + preprocessContext, beforeToolCall, afterToolCall, prepareNextTurn, -- 2.52.0 From e622a522dc7008813dd47069ee063cf3381d4262 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 21:45:00 +0700 Subject: [PATCH 016/112] update --- src/type.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/type.jl b/src/type.jl index 37ab0fc..f283de3 100644 --- a/src/type.jl +++ b/src/type.jl @@ -369,11 +369,12 @@ mutable struct yiemAgent <: agent # High-level agent wrapper _task::Union{Task, Nothing} # Background task running the agent loop - formatMsgForLLM::Function # Convert agent messages to LLM message format - # Preprocess/transform messages and context (modify, filter, prune, inject, reorder, add context) # for a single LLM call in _process_message()'s loop returns new Vector{agentMessage} preprocessContext ::Union{Function, Nothing} + + # Convert preprocessContext()'s new Vector{agentMessage} to LLM message format + formatMsgForLLM::Function # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} -- 2.52.0 From 6f971abe5d9038b7e9dc615fa0420bb124be76d1 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 21:46:49 +0700 Subject: [PATCH 017/112] update --- src/type.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/type.jl b/src/type.jl index f283de3..99dbdfa 100644 --- a/src/type.jl +++ b/src/type.jl @@ -369,8 +369,9 @@ mutable struct yiemAgent <: agent # High-level agent wrapper _task::Union{Task, Nothing} # Background task running the agent loop - # Preprocess/transform messages and context (modify, filter, prune, inject, reorder, add context) - # for a single LLM call in _process_message()'s loop returns new Vector{agentMessage} + # Preprocess/transform messages and context (modify, filter, prune, inject, reorder, + # add context from memory, ...) for a single LLM call in _process_message()'s loop. + # returns new Vector{agentMessage} preprocessContext ::Union{Function, Nothing} # Convert preprocessContext()'s new Vector{agentMessage} to LLM message format -- 2.52.0 From af0fa3010714a830d5ea6c26ee84d04e67bd0600 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 21:50:58 +0700 Subject: [PATCH 018/112] update --- src/type.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/type.jl b/src/type.jl index 99dbdfa..6cda17d 100644 --- a/src/type.jl +++ b/src/type.jl @@ -425,8 +425,8 @@ function yiemAgent( model=nothing, tools::Vector{agentTool}=agentTool[], messages::Vector{agentMessage}=agentMessage[], - formatMsgForLLM::Function=defaultformatMsgForLLM, preprocessContext::Union{Function, Nothing}=nothing, + formatMsgForLLM::Function=defaultformatMsgForLLM, beforeToolCall::Union{Function, Nothing}=nothing, afterToolCall::Union{Function, Nothing}=nothing, prepareNextTurn::Union{Function, Nothing}=nothing, @@ -447,8 +447,8 @@ function yiemAgent( followUp, outputChannel, nothing, # placeholder — replaced below - formatMsgForLLM, preprocessContext, + formatMsgForLLM, beforeToolCall, afterToolCall, prepareNextTurn, -- 2.52.0 From c6f97be63a8b90399bb75dc8ea31d4eb7919adab Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 22:03:56 +0700 Subject: [PATCH 019/112] update --- src/type.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/type.jl b/src/type.jl index 6cda17d..63193e6 100644 --- a/src/type.jl +++ b/src/type.jl @@ -369,8 +369,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper _task::Union{Task, Nothing} # Background task running the agent loop - # Preprocess/transform messages and context (modify, filter, prune, inject, reorder, - # add context from memory, ...) for a single LLM call in _process_message()'s loop. + # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, + # reorder, ...) for a single LLM call in _process_message()'s loop. # returns new Vector{agentMessage} preprocessContext ::Union{Function, Nothing} -- 2.52.0 From 2c0f25189bc19c4fde3d5f428d9a73598da3ed7c Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 22:17:58 +0700 Subject: [PATCH 020/112] update --- src/type.jl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/type.jl b/src/type.jl index 63193e6..72cb728 100644 --- a/src/type.jl +++ b/src/type.jl @@ -375,7 +375,9 @@ mutable struct yiemAgent <: agent # High-level agent wrapper preprocessContext ::Union{Function, Nothing} # Convert preprocessContext()'s new Vector{agentMessage} to LLM message format - formatMsgForLLM::Function + formatMsgForLLM::Function + + llmCall::Function # Actually invoke the LLM to get a completion response # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} @@ -402,6 +404,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `tools::Vector{agentTool}`: Available tools (default: empty) - `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) - `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) +- `llmCall::Function`: Function to invoke the LLM (required) - `preprocessContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) - `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) - `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) @@ -427,6 +430,7 @@ function yiemAgent( messages::Vector{agentMessage}=agentMessage[], preprocessContext::Union{Function, Nothing}=nothing, formatMsgForLLM::Function=defaultformatMsgForLLM, + llmCall::Function, beforeToolCall::Union{Function, Nothing}=nothing, afterToolCall::Union{Function, Nothing}=nothing, prepareNextTurn::Union{Function, Nothing}=nothing, @@ -449,6 +453,7 @@ function yiemAgent( nothing, # placeholder — replaced below preprocessContext, formatMsgForLLM, + llmCall, beforeToolCall, afterToolCall, prepareNextTurn, -- 2.52.0 From a90203dc0a741eb2f161c4c69542270981331cf0 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 23:04:44 +0700 Subject: [PATCH 021/112] update --- src/type.jl | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/type.jl b/src/type.jl index 72cb728..3362e05 100644 --- a/src/type.jl +++ b/src/type.jl @@ -376,11 +376,17 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Convert preprocessContext()'s new Vector{agentMessage} to LLM message format formatMsgForLLM::Function - - llmCall::Function # Actually invoke the LLM to get a completion response + + # Actually invoke the LLM to get a completion response. The LLM response comes back as an + # assistantMessage whose content is an array of content blocks. + # Each block has a type — "text", "thinking", or "toolCall". + # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). + llmCall::Function # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} + + executeToolCalls::Function # execute tool calls # Callback invoked after executing a tool call to sanitize tools output so the output is ready # to be converted into toolResults message @@ -389,7 +395,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - toolExecution::toolExecutionMode # Default: run tool calls sequentially or in parallel + parallelToolExecute::Bool # Default: false end """ @@ -412,7 +418,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) - `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) -- `toolExecution`: Default tool execution mode (sequential or parallel) (default: `nothing`) +- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) # Returns - A new `yiemAgent` instance with an active background task @@ -437,7 +443,7 @@ function yiemAgent( prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, - toolExecution=nothing, + parallelToolExecute::Bool=false, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -460,7 +466,7 @@ function yiemAgent( prepareNextTurnWithContext, sessionId, maxRetryDelayMs, - toolExecution, + parallelToolExecute, ) # Spawn the background loop and attach it -- 2.52.0 From ba13ccc4b9e43518e4ce4e4f75b63982d650cfaa Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 4 Aug 2026 23:17:13 +0700 Subject: [PATCH 022/112] update --- src/agentCore.jl | 17 ++++++++++------- src/type.jl | 4 ++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 6cc94d2..c2656c2 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -176,18 +176,21 @@ julia> # Currently returns a placeholder echo response function _process_message(agent::yiemAgent, msg)::assistantMessage # WORKING - # take every messages from agent.inputChannel, convert them into userMessage - # and add them to agent._state.messages + # loop until LLM didn't use tool calls + while + # take every messages from agent.inputChannel, convert them into userMessage + # and add them to agent._state.messages - # call agent.preprocessContext() + # call agent.preprocessContext() - # Call agent.formatMsgForLLM(agent._state) to format for LLM + # Call agent.formatMsgForLLM(agent._state) to format for LLM - # Call the LLM (blocking — the task waits here) + # Call the LLM (blocking — the task waits here) - # call toolArgumentValidation() to make sure soon-to-call tools has valid arguments + # call toolArgumentValidation() to make sure soon-to-call tools has valid arguments - # If agent has tools, handle tool calls in a loop + # If agent has tools, handle tool calls in a loop, save tool + end # Build assistantMessage and return it diff --git a/src/type.jl b/src/type.jl index 3362e05..bf6e0ce 100644 --- a/src/type.jl +++ b/src/type.jl @@ -204,7 +204,7 @@ A tool available to the agent. - `parameters::TParameters`: Tool parameters schema (JSON schema) - `execute::Function`: Tool execution function - `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback -- `executionMode::Union{toolExecutionMode, Nothing}`: Override: run tool calls sequentially or in parallel +- `parallelExecute::Union{toolparallelExecute, Nothing}`: Override: run tool calls sequentially or in parallel # Returns - A new `agentTool` instance @@ -216,7 +216,7 @@ struct agentTool{TParameters, TDetails} # A tool available to the agent parameters::TParameters # Tool parameters schema (JSON schema) execute::Function # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback - executionMode::Union{toolExecutionMode, Nothing} # Override: run tool calls sequentially or in parallel + parallelExecute::Union{toolparallelExecute, Nothing} # Override: run tool calls sequentially or in parallel end -- 2.52.0 From a3f9e39249dc26b121f1e286ad0a3e6448524324 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 5 Aug 2026 04:21:44 +0700 Subject: [PATCH 023/112] update --- etc.jl | 294 +++++++++++++++++++++++++++++++++++++++++++---- src/agentCore.jl | 16 +-- src/type.jl | 2 +- 3 files changed, 279 insertions(+), 33 deletions(-) diff --git a/etc.jl b/etc.jl index 5d9b549..46735d2 100644 --- a/etc.jl +++ b/etc.jl @@ -1,28 +1,272 @@ - - - -using Base.Threads - -println("Active Julia threads: ", nthreads()) - -# A CPU-heavy helper function -function compute_work(id, iterations) - println(" [Start] Task $id on Thread #", threadid()) - - total = 0.0 - for i in 1:iterations - total += sin(i) * cos(i) - end - - println(" [Done] Task $id on Thread #", threadid()) - return total +struct preparedToolCall + tool::AgentTool + toolCall::AgentToolCall + args::Any end -# ==================================================================== -# 1. Basic @spawn and fetch -# ==================================================================== -println("\n--- 1. Single Task Spawning ---") +struct immediateOutcome + result::AgentToolResult + isError::Bool +end -# Threads.@spawn creates a Task and schedules it onto an available worker thread -task1 = Threads.@spawn compute_work("A", 10_000_000) -println(typeof(task1)) \ No newline at end of file +struct executedOutcome + result::AgentToolResult + isError::Bool +end + +struct finalizedOutcome + toolCall::AgentToolCall + result::AgentToolResult + isError::Bool +end + +struct toolCallBatch + messages::Vector{ToolResultMessage} + terminate::Bool +end + +# ── helpers ───────────────────────────────────────────────────── + +function createErrorToolResult(msg::String)::AgentToolResult + return AgentToolResult([TextContent("text", msg)], Dict{Any,Any}()) +end + +function createToolResultMessage(f::finalizedOutcome)::ToolResultMessage + return ToolResultMessage( + "toolResult", f.toolCall.id, f.toolCall.name, + f.result.content, f.result.details, f.result.usage, + get(f.result, :addedToolNames, String[]), f.isError, now_millis() + ) +end + +function shouldTerminate(batches::Vector{finalizedOutcome})::Bool + return !isempty(batches) && all(b -> b.result.terminate, batches) +end + +# ── per-call preparation ──────────────────────────────────────── + +function prepareToolCall( + context::AgentContext, + assistantMsg::AssistantMessage, + toolCall::AgentToolCall, + config::AgentLoopConfig, + signal::Union{Nothing,AbortSignal}, +)::Union{preparedToolCall,immediateOutcome} + + tool = find(t -> t.name == toolCall.name, context.tools) + if tool === nothing + return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + end + + try + # 1. prepare arguments (tool-specific transform) + preparedArgs = prepareToolCallArguments(tool, toolCall) + validatedArgs = validateToolArguments(tool, preparedArgs) + + # 2. beforeToolCall hook + if config.before_tool_call !== nothing + before = config.before_tool_call( + AssistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + ) + if signal !== nothing && signal.aborted + return immediateOutcome(createErrorToolResult("Operation aborted"), true) + end + if before !== nothing && before.block + return immediateOutcome( + createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) + end + end + + return preparedToolCall(tool, toolCall, validatedArgs) + catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + end +end + +# ── per-call execution ────────────────────────────────────────── + +function executePreparedToolCall( + prep::preparedToolCall, + signal::Union{Nothing,AbortSignal}, + emit::AgentEventSink, +)::executedOutcome + + updateEvents = Promise[] + accepting = true + + try + result = prep.tool.execute( + prep.toolCall.id, prep.args, signal, + partialResult -> begin + if accepting + push!(updateEvents, + emit(ToolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + prep.toolCall.arguments, partialResult))) + end + end + ) + accepting = false + wait.(updateEvents) + return executedOutcome(result, false) + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end +end + +# ── per-call finalization ─────────────────────────────────────── + +function finalizeExecutedToolCall( + context::AgentContext, + assistantMsg::AssistantMessage, + prep::preparedToolCall, + executed::executedOutcome, + config::AgentLoopConfig, + signal::Union{Nothing,AbortSignal}, +)::finalizedOutcome + + result = executed.result + isError = executed.isError + + if config.afterToolCalls !== nothing + try + after = config.afterToolCalls( + AfterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + ) + if after !== nothing + result = merge(result, Dict(:content=>get(after,:content,result.content), + :details=>get(after,:details,result.details), + :usage=>get(after,:usage,result.usage), + :terminate=>get(after,:terminate,result.terminate))) + isError = get(after, :is_error, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true + end + end + + return finalizedOutcome(prep.toolCall, result, isError) +end + +function emitToolExecutionEnd(finalized::finalizedOutcome, emit::AgentEventSink) + emit(ToolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) +end + +# ── sequential execution ──────────────────────────────────────── + +function executeToolCallsSequential( + context::AgentContext, + assistantMsg::AssistantMessage, + toolCalls::Vector{AgentToolCall}, + config::AgentLoopConfig, + signal::Union{Nothing,AbortSignal}, + emit::AgentEventSink, +)::toolCallBatch + + finalizedCalls = finalizedOutcome[] + messages = ToolResultMessage[] + + for tc in toolCalls + emit(ToolExecStartEvent(tc.id, tc.name, tc.arguments)) + + prep = prepareToolCall(context, assistantMsg, tc, config, signal) + + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + else + executed = executePreparedToolCall(prep, signal, emit) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + end + + emitToolExecutionEnd(finalized, emit) + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + + if signal !== nothing && signal.aborted + break + end + end + + return toolCallBatch(messages, shouldTerminate(finalizedCalls)) +end + +# ── parallel execution ────────────────────────────────────────── + +function executeToolCallsParallel( + context::AgentContext, + assistantMsg::AssistantMessage, + toolCalls::Vector{AgentToolCall}, + config::AgentLoopConfig, + signal::Union{Nothing,AbortSignal}, + emit::AgentEventSink, +)::toolCallBatch + + # Each entry: finalizedOutcome (already done) or Task → finalizedOutcome (pending) + entries = Union{finalizedOutcome,Task{finalizedOutcome}}[] + + for tc in toolCalls + emit(ToolExecStartEvent(tc.id, tc.name, tc.arguments)) + + prep = prepareToolCall(context, assistantMsg, tc, config, signal) + + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + emitToolExecutionEnd(finalized, emit) + push!(entries, finalized) + else + # spawn lazy computation task + task = Task() do + executed = executePreparedToolCall(prep, signal, emit) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + emitToolExecutionEnd(finalized, emit) + return finalized + end + schedule(task) + push!(entries, task) + end + + if signal !== nothing && signal.aborted + break + end + end + + # Wait for all tasks, collect in order + finalizedCalls = finalizedOutcome[] + for entry in entries + outcome = entry isa Task ? fetch(entry) : entry + push!(finalizedCalls, outcome) + end + + messages = ToolResultMessage[] + for f in finalizedCalls + push!(messages, createToolResultMessage(f)) + end + + return toolCallBatch(messages, shouldTerminate(finalizedCalls)) +end + +# ── dispatcher ────────────────────────────────────────────────── + +function executeToolCalls( + context::AgentContext, + assistantMsg::AssistantMessage, + toolCalls::Vector{AgentToolCall}, + config::AgentLoopConfig, + signal::Union{Nothing,AbortSignal}, + emit::AgentEventSink, +)::toolCallBatch + + # Check if any tool is marked sequential, or config forces sequential + hasSequential = any(tc -> + any(t -> t.name == tc.name && get(t.executionMode, "parallel") == "sequential", + context.tools), toolCalls) + + if config.tool_execution == "sequential" || hasSequential + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) + else + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) + end +end diff --git a/src/agentCore.jl b/src/agentCore.jl index c2656c2..71f3bdb 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -176,7 +176,7 @@ julia> # Currently returns a placeholder echo response function _process_message(agent::yiemAgent, msg)::assistantMessage # WORKING - # loop until LLM didn't use tool calls + # loop until llmCall() response didn't use tool calls while # take every messages from agent.inputChannel, convert them into userMessage # and add them to agent._state.messages @@ -185,11 +185,15 @@ function _process_message(agent::yiemAgent, msg)::assistantMessage # Call agent.formatMsgForLLM(agent._state) to format for LLM - # Call the LLM (blocking — the task waits here) + # Call llmCall() (blocking — the task waits here) - # call toolArgumentValidation() to make sure soon-to-call tools has valid arguments + # if LLM use tool calls - # If agent has tools, handle tool calls in a loop, save tool + # call executeToolCalls() + + # else + # break out of while loop + end # Build assistantMessage and return it @@ -207,9 +211,7 @@ function _process_message(agent::yiemAgent, msg)::assistantMessage end -function createToolResultMessage()::toolResultMessage - -end + diff --git a/src/type.jl b/src/type.jl index bf6e0ce..71276fe 100644 --- a/src/type.jl +++ b/src/type.jl @@ -386,7 +386,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} - executeToolCalls::Function # execute tool calls + executeToolCalls::Function # execute tool calls () # Callback invoked after executing a tool call to sanitize tools output so the output is ready # to be converted into toolResults message -- 2.52.0 From afc866a3a5ffeac87b0db2e4f553d610192266bf Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 5 Aug 2026 10:27:17 +0700 Subject: [PATCH 024/112] update --- etc.jl | 488 +++++++++++++++++++++++++++++------ src/MCTSexamplePrompt.py | 537 --------------------------------------- src/agentCore.jl | 2 + src/type.jl | 11 +- 4 files changed, 418 insertions(+), 620 deletions(-) delete mode 100644 src/MCTSexamplePrompt.py diff --git a/etc.jl b/etc.jl index 46735d2..beb9929 100644 --- a/etc.jl +++ b/etc.jl @@ -1,57 +1,211 @@ +# ── executeToolCalls() Julia pseudo code ────────────────────────── +# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit + +""" + preparedToolCall(tool, toolCall, args) + +Intermediate state between tool call validation and execution. +This struct is created after `prepareToolCall` succeeds and serves +as the bridge to the execution phase. Keeping the resolved tool, +original call metadata, and validated args together avoids repeated +lookups and allows the execution phase to access all necessary data +without carrying the full context through the call chain. +""" struct preparedToolCall - tool::AgentTool - toolCall::AgentToolCall - args::Any + tool::agentTool # The resolved tool definition from the context + toolCall::agentToolCall # The original tool call from the assistant + args::any # Validated (and coerced) argument values end +""" + immediateOutcome(result, isError) + +A tool call that was resolved without actual execution — either +because the tool was not found, validation failed, or a +`beforeToolCall` hook blocked the call. The result is produced +immediately and emitted as a tool result message. + +Returning an outcome instead of throwing an exception is intentional: +it lets the agent feed the error back to the LLM as a tool result so +the model can recover — for example, by re-issuing a tool call with +corrected arguments after a validation failure. +""" struct immediateOutcome - result::AgentToolResult - isError::Bool + result::agentToolResult # The pre-computed tool result + isError::bool # Whether this outcome represents an error end +""" + executedOutcome(result, isError) + +A tool call that has been executed by `tool.execute()` but has not +yet been through the `afterToolCall` hook. This intermediate state +is necessary because the hook may mutate the result (content, usage, +termination, error status). Keeping execution and finalization separate +allows the hook to inspect the raw result and decide whether to +transform it or replace it entirely. +""" struct executedOutcome - result::AgentToolResult - isError::Bool + result::agentToolResult # The tool's execution result + isError::bool # Whether execution raised an error end +""" + finalizedOutcome(toolCall, result, isError) + +The complete outcome of a tool call after both execution and the +`afterToolCall` hook. This is the final form that is used to +construct the `toolResultMessage` emitted to the agent loop. + +The three-phase design (prepare → execute → finalize) exists so that +each phase has a single responsibility: preparation handles validation +and gating, execution performs the actual work, and finalization +applies post-processing hooks. This separation allows the agent loop +to emit `tool_execution_end` events with the finalized data while +keeping each phase independently testable and swappable. +""" struct finalizedOutcome - toolCall::AgentToolCall - result::AgentToolResult - isError::Bool + toolCall::agentToolCall # The original tool call reference + result::agentToolResult # The final tool result (post-afterToolCall) + isError::bool # Whether the call failed or was blocked end +""" + ToolCallBatch(messages, terminate) + +A batch of tool result messages from executing one or more tool calls. +The `terminate` flag indicates whether all tools in the batch requested +termination, which causes the agent loop to stop processing further turns. + +This flag is set by the tool implementation (not the end user) to signal +that the agent should not call the LLM again. Typical use cases: + +- Task completion: a tool like `deploy` or `submit` finishes its work and + returns `terminate: true` so the agent stops instead of asking the LLM + what to do next. +- Unrecoverable error: a tool hits a fatal condition (e.g. database + connection lost, auth token expired) and returns `terminate: true` so + the agent stops with an error message rather than retrying. +- Async handoff: a tool triggers a long-running external operation and + wants the agent to stop now; the external system will later resume the + agent via `continue()`. + +If `terminate` is `false` (default), the agent loop feeds the tool results +back to the LLM for another turn. +""" struct toolCallBatch - messages::Vector{ToolResultMessage} - terminate::Bool + messages::vector{toolResultMessage} # Tool result messages for this batch + terminate::bool # Whether the batch should terminate the loop end -# ── helpers ───────────────────────────────────────────────────── +""" + createErrorToolResult(msg) -function createErrorToolResult(msg::String)::AgentToolResult - return AgentToolResult([TextContent("text", msg)], Dict{Any,Any}()) +Builds an `agentToolResult` containing a single text content item +with the provided error message and an empty details dictionary. +Used when a tool call cannot be executed due to errors. + +Returning a result instead of throwing ensures that errors at any +point in the tool call pipeline are fed back to the LLM as a tool +result message. This allows the model to see the error and decide +whether to retry, re-issue the call with different arguments, or +report failure to the user. +""" +function createErrorToolResult(msg::String)::agentToolResult + return agentToolResult([textContent("text", msg)], dict{any,any}()) end -function createToolResultMessage(f::finalizedOutcome)::ToolResultMessage - return ToolResultMessage( +""" + createToolResultMessage(f) + +Constructs a `toolResultMessage` from a `finalizedOutcome`. +Normalizes missing content to an empty array and includes +the `addedToolNames` field only when the tool dynamically +registered new tools during execution. + +This conversion is necessary because the tool result is an +`agentToolResult` used by tool implementations, while the agent +loop consumes `toolResultMessage` objects that become part of the +conversation history. The message format includes metadata like +timestamp and tool call ID that the raw result does not carry, +and it is the object emitted via `message_start`/`message_end` +events so the LLM receives the result as a proper assistant/user +message in the context window. +""" +function createToolResultMessage(f::finalizedOutcome)::toolResultMessage + return toolResultMessage( "toolResult", f.toolCall.id, f.toolCall.name, f.result.content, f.result.details, f.result.usage, - get(f.result, :addedToolNames, String[]), f.isError, now_millis() + get(f.result, :addedToolNames, string[]), f.isError, nowMillis() ) end -function shouldTerminate(batches::Vector{finalizedOutcome})::Bool +""" + shouldTerminate(finalizedCalls) + +Returns `true` only when every finalized call in the batch has +`result.terminate == true`. All tools must agree — if any tool +did not request termination, the agent continues. This prevents +a single tool that happens to set `terminate: true` (e.g. for +metadata purposes) from accidentally stopping the agent when +other tools in the batch did not intend to terminate. +""" +function shouldTerminate(batches::vector{finalizedOutcome})::bool return !isempty(batches) && all(b -> b.result.terminate, batches) end -# ── per-call preparation ──────────────────────────────────────── +""" + prepareToolCallArguments(tool, toolCall) +Calls the tool's optional `prepareArguments` hook to transform +the raw argument values from the LLM before schema validation. +If the tool has no hook or the hook returns the same object +reference, the original call is returned unchanged. + +This hook allows tools to normalize arguments that the LLM may +have produced in a non-standard format — for example, converting +a date string to a timestamp, expanding a short file path to an +absolute path, or normalizing casing. It runs before schema +validation so the validator sees the normalized form rather than +raw LLM output. +""" +function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall + if tool.prepareArguments === nothing + return toolCall + end + prepared = tool.prepareArguments(toolCall.arguments) + if prepared == toolCall.arguments + return toolCall + end + return merge(toolCall, dict(:arguments => prepared)) +end + +""" + prepareToolCall(context, assistantMsg, toolCall, config, signal) + +Resolves the tool by name, prepares and validates its arguments, +and runs the `beforeToolCall` hook. Returns a `preparedToolCall` +if successful or an `immediateOutcome` if the tool is not found, +validation fails, the hook blocks execution, or the signal is +aborted. Errors during preparation are caught and returned as +immediate error outcomes so the agent loop can feed them back +to the model. + +The key design decision here is that preparation never throws. +Every failure path returns an `immediateOutcome` with an error +result. This ensures the agent loop always receives a valid tool +result message for every tool call the assistant requested, +regardless of whether preparation succeeded. The LLM can then +use the error message to decide whether to retry with different +arguments or acknowledge the failure. +""" function prepareToolCall( - context::AgentContext, - assistantMsg::AssistantMessage, - toolCall::AgentToolCall, - config::AgentLoopConfig, - signal::Union{Nothing,AbortSignal}, -)::Union{preparedToolCall,immediateOutcome} + context::agentContext, + assistantMsg::assistantMessage, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, +)::union{preparedToolCall,immediateOutcome} tool = find(t -> t.name == toolCall.name, context.tools) if tool === nothing @@ -60,13 +214,13 @@ function prepareToolCall( try # 1. prepare arguments (tool-specific transform) - preparedArgs = prepareToolCallArguments(tool, toolCall) - validatedArgs = validateToolArguments(tool, preparedArgs) + prepared = prepareToolCallArguments(tool, toolCall) + validatedArgs = validateToolArguments(tool, prepared) - # 2. beforeToolCall hook - if config.before_tool_call !== nothing - before = config.before_tool_call( - AssistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + # 2. beforeToolCall hook — can block + if config.beforeToolCall !== nothing + before = config.beforeToolCall( + assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal ) if signal !== nothing && signal.aborted return immediateOutcome(createErrorToolResult("Operation aborted"), true) @@ -85,13 +239,32 @@ end # ── per-call execution ────────────────────────────────────────── +""" + executePreparedToolCall(prep, signal, emit) + +Executes the tool by calling `tool.execute()` with the validated +arguments, the abort signal, and a callback for streaming partial +results. Emits `toolExecutionUpdate` events for each partial +result batch. Waits for all pending update events to settle before +returning. Catches execution errors and returns them as an error +outcome. The `accepting` guard prevents emitting updates after +the call has finished. + +Long-running tools (e.g. file uploads, model training, web scraping) +may take seconds or minutes. The streaming update mechanism allows +UI listeners and other consumers to show progress in real time rather +than waiting for the entire call to complete. The `accepting` guard +ensures that if the tool's execute function yields after emitting +updates but before returning, no duplicate or stale updates are +emitted after the result has already been captured. +""" function executePreparedToolCall( prep::preparedToolCall, - signal::Union{Nothing,AbortSignal}, - emit::AgentEventSink, + signal::union{nothing,abortSignal}, + emit::agentEventSink, )::executedOutcome - updateEvents = Promise[] + updateEvents = promise[] accepting = true try @@ -100,7 +273,7 @@ function executePreparedToolCall( partialResult -> begin if accepting push!(updateEvents, - emit(ToolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, prep.toolCall.arguments, partialResult))) end end @@ -117,29 +290,56 @@ end # ── per-call finalization ─────────────────────────────────────── +""" + finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + +Runs the `afterToolCall` hook on the executed result, allowing +the consumer to mutate the result content, details, usage, +termination flag, or error status. Catches errors from the +hook and converts them to error outcomes. Returns a +`finalizedOutcome` that is used to construct the tool result +message. + +The `afterToolCall` hook exists as a post-processing step that +runs after every tool call regardless of success or failure. +Common use cases include: + +- Masking sensitive data from result content before the LLM + sees it (e.g. removing API keys from error messages). +- Normalizing usage tracking data into a consistent format. +- Inspecting the result and deciding to flip `terminate: true` + based on business logic (e.g. "if deployment failed, stop + the agent rather than retrying"). +- Wrapping an error result in a friendlier message for the LLM + to understand. + +If the hook itself throws, the error is caught and the result +becomes an error outcome. This ensures the tool pipeline never +breaks due to a buggy hook. +""" function finalizeExecutedToolCall( - context::AgentContext, - assistantMsg::AssistantMessage, + context::agentContext, + assistantMsg::assistantMessage, prep::preparedToolCall, executed::executedOutcome, - config::AgentLoopConfig, - signal::Union{Nothing,AbortSignal}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, )::finalizedOutcome result = executed.result isError = executed.isError - if config.afterToolCalls !== nothing + if config.afterToolCall !== nothing try - after = config.afterToolCalls( - AfterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + after = config.afterToolCall( + afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) if after !== nothing - result = merge(result, Dict(:content=>get(after,:content,result.content), + result = merge(result, dict(:content=>get(after,:content,result.content), :details=>get(after,:details,result.details), :usage=>get(after,:usage,result.usage), :terminate=>get(after,:terminate,result.terminate))) - isError = get(after, :is_error, isError) + isError = get(after, :isError, isError) end catch err result = createErrorToolResult(sprint(showerror, err)) @@ -150,27 +350,59 @@ function finalizeExecutedToolCall( return finalizedOutcome(prep.toolCall, result, isError) end -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::AgentEventSink) - emit(ToolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, +""" + emitToolExecutionEnd(finalized, emit) + +Emits the `toolExecutionEnd` event with the finalized outcome, +signalling to listeners that the tool call has completed. + +This event is part of the tool execution lifecycle: +`tool_execution_start` → (zero or more `tool_execution_update` events) → +`tool_execution_end`. Listeners (such as the TUI or logging systems) +use this lifecycle to track individual tool calls. The event carries +the final result so listeners have all the data they need without +requiring external state lookups. +""" +function emitToolExecutionEnd(finalized::finalizedOutcome, emit::agentEventSink) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) end # ── sequential execution ──────────────────────────────────────── +""" + executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) + +Executes tool calls one at a time in the order they appear. For each +call: emits `toolExecutionStart`, runs `prepareToolCall`, +then either resolves the immediate outcome or executes/finalizes +the prepared call. Emits `toolExecutionEnd` and creates the tool +result message before proceeding to the next call. Respects the +abort signal — if aborted, remaining calls are skipped. Returns +a batch with `terminate` determined by whether all results set +the termination flag. + +Sequential execution is required when tool calls have implicit +dependencies — for example, a `create_database` tool must complete +before `create_table` can reference it. It is also the safer +default because it prevents race conditions when multiple tools +share state (e.g. writing to the same file or API rate limits). +Use parallel only when you are confident the tools are independent. +""" function executeToolCallsSequential( - context::AgentContext, - assistantMsg::AssistantMessage, - toolCalls::Vector{AgentToolCall}, - config::AgentLoopConfig, - signal::Union{Nothing,AbortSignal}, - emit::AgentEventSink, -)::toolCallBatch + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::agentEventSink, +)::agentToolCallBatch finalizedCalls = finalizedOutcome[] - messages = ToolResultMessage[] + messages = toolResultMessage[] for tc in toolCalls - emit(ToolExecStartEvent(tc.id, tc.name, tc.arguments)) + emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) prep = prepareToolCall(context, assistantMsg, tc, config, signal) @@ -195,20 +427,40 @@ end # ── parallel execution ────────────────────────────────────────── -function executeToolCallsParallel( - context::AgentContext, - assistantMsg::AssistantMessage, - toolCalls::Vector{AgentToolCall}, - config::AgentLoopConfig, - signal::Union{Nothing,AbortSignal}, - emit::AgentEventSink, -)::toolCallBatch +""" + executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) - # Each entry: finalizedOutcome (already done) or Task → finalizedOutcome (pending) - entries = Union{finalizedOutcome,Task{finalizedOutcome}}[] +Prepares all tool calls concurrently and spawns a task for each +prepared call. Immediate outcomes are resolved instantly. Task +entries are collected in order, then `fetch`ed to await all +concurrent executions. Tool result messages are created from +finalized outcomes in order and returned as a batch. Respects +the abort signal — if aborted during preparation, remaining +calls are skipped. Finalization order preserves the original +call order. + +Parallel execution is appropriate when the assistant requests +independent tools — for example, reading multiple files, querying +separate databases, or making independent API calls. It reduces +wall-clock time compared to sequential execution. The tradeoff is +that parallel calls can overwhelm external resources (rate limits, +connection pools, disk I/O). Finalization preserves the original +call order so tool result messages appear in the same order the +assistant requested them, regardless of which call finishes first. +""" +function executeToolCallsParallel( + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::agentEventSink, +)::agentToolCallBatch + + entries = union{finalizedOutcome,task{finalizedOutcome}}[] for tc in toolCalls - emit(ToolExecStartEvent(tc.id, tc.name, tc.arguments)) + emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) prep = prepareToolCall(context, assistantMsg, tc, config, signal) @@ -217,8 +469,7 @@ function executeToolCallsParallel( emitToolExecutionEnd(finalized, emit) push!(entries, finalized) else - # spawn lazy computation task - task = Task() do + task = task() do executed = executePreparedToolCall(prep, signal, emit) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) emitToolExecutionEnd(finalized, emit) @@ -233,14 +484,13 @@ function executeToolCallsParallel( end end - # Wait for all tasks, collect in order finalizedCalls = finalizedOutcome[] for entry in entries - outcome = entry isa Task ? fetch(entry) : entry + outcome = entry isa task ? fetch(entry) : entry push!(finalizedCalls, outcome) end - messages = ToolResultMessage[] + messages = toolResultMessage[] for f in finalizedCalls push!(messages, createToolResultMessage(f)) end @@ -248,25 +498,99 @@ function executeToolCallsParallel( return toolCallBatch(messages, shouldTerminate(finalizedCalls)) end -# ── dispatcher ────────────────────────────────────────────────── +""" + executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit) + +Dispatches to sequential or parallel execution. Uses sequential mode +when `config.toolExecution == "sequential"` or when any of the +tool calls reference a tool with `executionMode: "sequential"`. +Otherwise uses parallel execution. This is the entry point called +from `streamAssistantResponse` in the agent loop. + +The sequential mode takes priority over parallel because it is the +safe default. If even one tool in a batch is marked sequential, all +tools execute sequentially — this prevents a single dependent tool +from racing with an otherwise independent one. The per-tool +`executionMode` allows fine-grained control (e.g. most tools are +parallel but a specific write tool is sequential), while the config-level +`toolExecution` provides a global override. +""" function executeToolCalls( - context::AgentContext, - assistantMsg::AssistantMessage, - toolCalls::Vector{AgentToolCall}, - config::AgentLoopConfig, - signal::Union{Nothing,AbortSignal}, - emit::AgentEventSink, -)::toolCallBatch + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::agentEventSink, +)::agentToolCallBatch - # Check if any tool is marked sequential, or config forces sequential hasSequential = any(tc -> any(t -> t.name == tc.name && get(t.executionMode, "parallel") == "sequential", context.tools), toolCalls) - if config.tool_execution == "sequential" || hasSequential + if config.toolExecution == "sequential" || hasSequential return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) else return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) end end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MCTSexamplePrompt.py b/src/MCTSexamplePrompt.py deleted file mode 100644 index 9b52f9e..0000000 --- a/src/MCTSexamplePrompt.py +++ /dev/null @@ -1,537 +0,0 @@ -https://github.com/andyz245/LanguageAgentTreeSearch/blob/main/hotpot/hotpot.py - - -standard_prompt = ''' -Write a coherent passage of 4 short paragraphs. The end sentence of each paragraph must be: {input} -''' - -reflection_prompt = '''You are an advanced reasoning agent that can improve based on self refection. You will be given a previous reasoning trial in which you were given access to an Docstore API environment and a question to answer. You were unsuccessful in answering the question either because you guessed the wrong answer with Finish[], or you used up your set number of reasoning steps. In a few sentences, Diagnose a possible reason for failure and devise a new, concise, high level plan that aims to mitigate the same failure. Use complete sentences. -Here are some examples: -Previous Trial: -Question: The Rome Protocols were signed by three Prime Ministers one of which was assassinated as part of what? -Thought 1: I need to search Rome Protocols, find the three Prime Ministers, then find what they were assassinated as part of. -Action 1: Search[Rome Protocols] -Observation 1: The Rome Protocols were a series of three international agreements concluded in Rome on 17 March 1934 between the governments of Austria, Hungary and Italy. They were signed by Italian Prime Minister Benito Mussolini, Austrian Chancellor Engelbert Dollfuss and Hungarian Prime Minister Gyula Gömbös. All of the protocols went into effect on 12 July 1934 and were registered in League of Nations Treaty Series on 12 December 1934. -Thought 2: The three Prime Ministers were Benito Mussolini, Engelbert Dollfuss, and Gyula Gömbös. I need to search Benito Mussolini and find what he was assassinated as part of. -Action 2: Search[Benito Mussolini] -Observation 2: Benito Amilcare Andrea Mussolini (UK: MU(U)SS-ə-LEE-nee, US: MOOSS-, Italian: [beˈniːto aˈmilkare anˈdrɛːa mussoˈliːni]; 29 July 1883 – 28 April 1945) was an Italian politician and journalist who founded and led the National Fascist Party (PNF). He was Prime Minister of Italy from the March on Rome in 1922 until his deposition in 1943, as well as "Duce" of Italian fascism from the establishment of the Italian Fasces of Combat in 1919 until his summary execution in 1945 by Italian partisans. As dictator of Italy and principal founder of fascism, Mussolini inspired and supported the international spread of fascist movements during the inter-war period.Mussolini was originally a socialist politician and a journalist at the Avanti! newspaper. In 1912, he became a member of the National Directorate of the Italian Socialist Party (PSI), but he was expelled from the PSI for advocating military intervention in World War I, in opposition to the party's stance on neutrality. In 1914, Mussolini founded a new journal, Il Popolo d'Italia, and served in the Royal Italian Army during the war until he was wounded and discharged in 1917. Mussolini denounced the PSI, his views now centering on Italian nationalism instead of socialism, and later founded the fascist movement which came to oppose egalitarianism and class conflict, instead advocating "revolutionary nationalism" transcending class lines. On 31 October 1922, following the March on Rome (28–30 October), Mussolini was appointed prime minister by King Victor Emmanuel III, becoming the youngest individual to hold the office up to that time. After removing all political opposition through his secret police and outlawing labor strikes, Mussolini and his followers consolidated power through a series of laws that transformed the nation into a one-party dictatorship. Within five years, Mussolini had established dictatorial authority by both legal and illegal means and aspired to create a totalitarian state. In 1929, Mussolini signed the Lateran Treaty with the Holy See to establish Vatican City. -Mussolini's foreign policy aimed to restore the ancient grandeur of the Roman Empire by expanding Italian colonial possessions and the fascist sphere of influence. In the 1920s, he ordered the Pacification of Libya, instructed the bombing of Corfu over an incident with Greece, established a protectorate over Albania, and incorporated the city of Fiume into the Italian state via agreements with Yugoslavia. In 1936, Ethiopia was conquered following the Second Italo-Ethiopian War and merged into Italian East Africa (AOI) with Eritrea and Somalia. In 1939, Italian forces annexed Albania. Between 1936 and 1939, Mussolini ordered the successful Italian military intervention in Spain in favor of Francisco Franco during the Spanish Civil War. Mussolini's Italy initially tried to avoid the outbreak of a second global war, sending troops at the Brenner Pass to delay Anschluss and taking part in the Stresa Front, the Lytton Report, the Treaty of Lausanne, the Four-Power Pact and the Munich Agreement. However, Italy then alienated itself from Britain and France by aligning with Germany and Japan. Germany invaded Poland on 1 September 1939, resulting in declarations of war by France and the UK and the start of World War II. -On 10 June 1940, Mussolini decided to enter the war on the Axis side. Despite initial success, the subsequent Axis collapse on multiple fronts and eventual Allied invasion of Sicily made Mussolini lose the support of the population and members of the Fascist Party. As a consequence, early on 25 July 1943, the Grand Council of Fascism passed a motion of no confidence in Mussolini; later that day King Victor Emmanuel III dismissed him as head of government and had him placed in custody, appointing Pietro Badoglio to succeed him as Prime Minister. After the king agreed to an armistice with the Allies, on 12 September 1943 Mussolini was rescued from captivity in the Gran Sasso raid by German paratroopers and Waffen-SS commandos led by Major Otto-Harald Mors. Adolf Hitler, after meeting with the rescued former dictator, then put Mussolini in charge of a puppet regime in northern Italy, the Italian Social Republic (Italian: Repubblica Sociale Italiana, RSI), informally known as the Salò Republic, causing a civil war. In late April 1945, in the wake of near total defeat, Mussolini and his mistress Clara Petacci attempted to flee to Switzerland, but both were captured by Italian communist partisans and summarily executed by firing squad on 28 April 1945 near Lake Como. The bodies of Mussolini and his mistress were then taken to Milan, where they were hung upside down at a service station to publicly confirm their demise. -Thought 3: Benito Mussolini was assassinated as part of the collapse of the Axis powers during World War II. So the Rome Protocols were signed by three Prime Ministers one of which was assassinated as part of World War II. -Action 3: Finish[World War II] - -Reflection: I searched one of the prime ministers involved in the signing, then attemted to answer right away. I should have searched each of the prime ministers, then looked up 'death' on each of their pages in order to get more information before answering. - -Previous Trial: -Question: Kam Heskin plays Paige Morgan in a 2004 film directed by who? -Thought 1: I need to search Kam Heskin and Paige Morgan, find the 2004 film they are in, then find the director of the film. -Action 1: Search[Kam Heskin] -Observation 1: Kam Erika Heskin (born May 8, 1973) is an American actress. She began her career playing Caitlin Deschanel on the NBC daytime soap opera Sunset Beach (1998–1999), before appearing in films Planet of the Apes (2001) and Catch Me If You Can (2002). Heskin went on in 2003 to play Elizabeth Bennet in an independent film Pride & Prejudice: A Latter-Day Comedy, Robin in Charmed (2004), and Paige Morgan in The Prince and Me film franchise (2006–2010). -Thought 2: Kam Heskin plays Paige Morgan in The Prince and Me film franchise. I need to search The Prince and Me film franchise and find the director of the 2004 film. -Action 2: Search[The Prince and Me film franchise] -Observation 2: Could not find [The Prince and Me film franchise]. Similar: ['Kam Heskin', 'Prince of Persia', 'Harry Potter and the Half-Blood Prince (film)', 'Prince of Persia: The Sands of Time (film)', 'The Chronicles of Narnia (film series)', 'Majestic Prince (franchise)', 'Kingsman (franchise)', 'The Little Prince (2015 film)', 'Shrek (franchise)', 'Harry Potter (film series)'] -Thought 3: I need to search The Prince and Me (2004 film) instead. -Action 3: Search[The Prince and Me (2004 film)] -Observation 3: Could not find [The Prince and Me (2004 film)]. Similar: ['The Prince & Me', 'The Prince & Me 2: The Royal Wedding', 'Kam Heskin', 'Prince of Darkness (film)', 'Prince of Persia: The Sands of Time (film)', 'Rob Knox', 'Alexander (2004 film)', 'Prince of Persia', 'Brooklynn Prince', 'Prince (musician)'] -Thought 4: I need to search The Prince & Me (2004 film). -Action 4: Search[The Prince & Me (2004 film)] -Observation 4: Could not find [The Prince & Me (2004 film)]. Similar: ['The Prince & Me', 'The Prince & Me 2: The Royal Wedding', 'Prince of Darkness (film)', 'Prince of Persia: The Sands of Time (film)', 'Rob Knox', 'Alexander (2004 film)', 'Prince (musician)', 'Prince of Persia', 'Kam Heskin', 'Brooklynn Prince'] -Thought 5: I need to search The Prince & Me (2004 film) instead. -Action 5: Search[The Prince & Me (2004 film)] -Observation 5: Could not find [The Prince & Me (2004 film)]. Similar: ['The Prince & Me', 'The Prince & Me 2: The Royal Wedding', 'Prince of Darkness (film)', 'Prince of Persia: The Sands of Time (film)', 'Rob Knox', 'Alexander (2004 film)', 'Prince (musician)', 'Prince of Persia', 'Kam Heskin', 'Brooklynn Prince'] -Thought 6: I need to search The Prince & Me (2004 film). -Action 6: Search[The Prince & Me (2004 film)] -Observation 6: Could not find [The Prince & Me (2004 film)]. Similar: ['The Prince & Me', 'The Prince & Me 2: The Royal Wedding', 'Prince of Darkness (film)', 'Prince of Persia: The Sands of Time (film)', 'Rob Knox', 'Alexander (2004 film)', 'Prince (musician)', 'Prince of Persia', 'Kam Heskin', 'Brooklynn Prince'] - -Reflection: I got stuck in a loop where I kept trying to search 'The Prince & Me (2004 film)' but the page could not be found. Instead I should have tried to search the similar results that had a similar name to see and they were made in 2004. - -Previous Trial: -Question: The Oberoi family is part of a hotel company that has a head office in what city? -Thought 1: I need to search the Oberoi family and find the city where the hotel company's head office is located. -Action 1: Search[Oberoi family] -Observation 1: The Oberoi Group is a luxury hotel group with its head office in New Delhi, India.[2] Founded in 1934, the company owns and operates 32 luxury hotels and two river cruise ships in 7 countries, primarily under its Oberoi Hotels & Resorts and Trident brands.[3] The group also operates The Oberoi Centre for Learning and Development, which is regarded as one of Asia's top institutions for hospitality education.[4]. The foundations of the Oberoi Group date back to 1934 when The Rai Bahadur Mohan Singh Oberoi, the founder of the group, bought two properties: the Maidens in Delhi and the Clarke's in Shimla.[5] In the following years Oberoi, assisted by his two sons, Tilak Raj Singh Oberoi and Prithvi Raj Singh Oberoi (P.R.S. Oberoi -Thought 2: The Oberoi Group has its head office in New Delhi, India. I just need to finish with New Delhi. -Action 2: Finish[New Delhi] -Observation 2: Episode finished, reward = 0 - -Reflection: I appeared to have retrieved the correct information about The Oberoi Family and the location of it's head office, and provided a corresponding answer. However this answer does not exactly match the ground truth answer so I should try a different wording, such as Delhi. - -Previous trial: -{trajectory}Reflection:''' - -cot_prompt = ''' -Solve a question answering task with interleaving Thought, Action, Observation steps. Thought can reason about the current situation, and Action can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. -After each observation, provide the next Thought and next Action. Here are some examples: - -Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into? -Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area. -Action 1: Search[Colorado orogeny] -Observation 1: The Colorado orogeny was an episode of mountain building (an orogeny) in Colorado and surrounding areas. This took place from 1780 to 1650 million years ago (Mya), during the Paleoproterozoic (Statherian Period). It is recorded in the Colorado orogen, a >500-km-wide belt of oceanic arc rock that extends southward into New Mexico. The Colorado orogeny was likely part of the larger Yavapai orogeny. -Thought 2: It does not mention the eastern sector of the Colorado orogeny. I need to look up eastern sector. -Action 2: Lookup[eastern sector] -Observation 2: (Result 1 / 1) The Colorado orogen, formerly called the Colorado province, is a >500-km-wide belt of oceanic arc rock (1.78\u20131.65 Ga) that extends southward into New Mexico and composes a major part of the Proterozoic provinces of southwestern United States. This transcontinental collisional event occurred during the Paleoproterozoic (Statherian Period).[1] The Wyoming sector of the Colorado orogeny was formerly called the Medicine Bow orogeny. The eastern sector extends into the High Plains and is called the Central Plains orogeny. The boundary between the Colorado orogeny and the Wyoming craton is the Cheyenne belt, a 5-km-wide mylonitic shear zone that verges northward. The Cheyenne belt transects and cuts off the south edge of the older Trans-Hudson orogeny.[2] -Thought 3: The eastern sector of Colorado orogeny extends into the High Plains, so High Plains is the area. I need to search High Plains and find its elevation range. -Action 3: Search[High Plains]Observation 3: High Plains refers to one of two distinct land regions: -Thought 4: I need to search High Plains (United States). -Action 4: Search[High Plains (United States)] -Observation 4: The High Plains are a subregion of the Great Plains, mainly in the Western United States, but also partly in the Midwest states of Nebraska, Kansas, and South Dakota, generally encompassing the western part of the Great Plains before the region reaches the Rocky Mountains. The High Plains are located in eastern Montana, southeastern Wyoming, southwestern South Dakota, western Nebraska, eastern Colorado, western Kansas, eastern New Mexico, western Oklahoma, and to just south of the Texas Panhandle.[4] The southern region of the Western High Plains ecology region contains the geological formation known as Llano Estacado which can be seen from a short distance or on satellite maps.[5] From east to west, the High Plains rise in elevation from around 1,800 to 7,000 ft (550 to 2,130 m).[3] -Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft. -Action 5: Finish[1,800 to 7,000 ft] - -Question: Guitars for Wounded Warriors is an album that was recorded in the village in which New York county? -Thought 1: I need to search Guitars for Wounded Warriors, then find in which New York county it was recorded. -Action 1: Search[Guitars for Wounded Warriors] -Observation 1: Guitars for Wounded Warriors is the sixth solo studio album by guitarist Brian Tarquin, released in August 2014 by Cleopatra Records. In 2016, it received a Best Compilation Album nomination by the Independent Music Awards. All tracks were recorded at Tarquin's Jungle Room Studios in New Paltz (village), New York. Being moved by the lack of support for returning veterans through his life Tarquin decided to compose a heavy instrumental rock album as a way to show his appreciation to all veterans. So once again he enlisted top notch players to guest with him on the album, Billy Sheehan on Black Hawk, Ron \"Bumblefoot\" Thal and Reb Beach on Taliban Terror, Steve Morse on Freedom, Chris Poland on Charlie Surfs and Hunting, Chuck Loeb on Escape Kabul, Hal Lindes on Sand & Blood, Gary Hoey on 5 Klicks To Hell and Baghdad, Alex De Rosso Dokken on Broken Arrow, and The Flyin' Ryan Brothers on Surgical Strike. The entire album was engineered, produced and composed by Tarquin especially for each one of the guest guitarists. Partial proceeds are donated to the Fisher House Foundation from sales. -Thought 2: Guitars for Wounded Warriors was recorded in New Paltz (village), New York. I need to search New Paltz (village), New York and find which county it is in -Action 2: Search[New Paltz (village), New York] -Observation 2: New Paltz is a village in Ulster County located in the U.S. state of New York. It is approximately 80 miles (130 km) north of New York City and 70 miles (110 km) south of Albany. The population was 7,324 at the 2020 census.[3] -Thought 3: New Paltz (village), New York is in Ulster County, so the answer is Ulster County. -Action 3: Finish[Ulster County] - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] - -{input} -''' - -cot_prompt_short = ''' -Solve a question answering task with interleaving Thought, Action, Observation steps. Thought can reason about the current situation, and Action can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. -After each observation, provide the next Thought and next Action. Here are some examples: - -Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into? -Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area. -Action 1: Search[Colorado orogeny] -Observation 1: The Colorado orogeny was an episode of mountain building (an orogeny) in Colorado and surrounding areas. This took place from 1780 to 1650 million years ago (Mya), during the Paleoproterozoic (Statherian Period). It is recorded in the Colorado orogen, a >500-km-wide belt of oceanic arc rock that extends southward into New Mexico. The Colorado orogeny was likely part of the larger Yavapai orogeny. -Thought 2: It does not mention the eastern sector of the Colorado orogeny. I need to look up eastern sector. -Action 2: Lookup[eastern sector] -Observation 2: (Result 1 / 1) The Colorado orogen, formerly called the Colorado province, is a >500-km-wide belt of oceanic arc rock (1.78\u20131.65 Ga) that extends southward into New Mexico and composes a major part of the Proterozoic provinces of southwestern United States. This transcontinental collisional event occurred during the Paleoproterozoic (Statherian Period).[1] The Wyoming sector of the Colorado orogeny was formerly called the Medicine Bow orogeny. The eastern sector extends into the High Plains and is called the Central Plains orogeny. The boundary between the Colorado orogeny and the Wyoming craton is the Cheyenne belt, a 5-km-wide mylonitic shear zone that verges northward. The Cheyenne belt transects and cuts off the south edge of the older Trans-Hudson orogeny.[2] -Thought 3: The eastern sector of Colorado orogeny extends into the High Plains, so High Plains is the area. I need to search High Plains and find its elevation range. -Action 3: Search[High Plains]Observation 3: High Plains refers to one of two distinct land regions: -Thought 4: I need to search High Plains (United States). -Action 4: Search[High Plains (United States)] -Observation 4: The High Plains are a subregion of the Great Plains, mainly in the Western United States, but also partly in the Midwest states of Nebraska, Kansas, and South Dakota, generally encompassing the western part of the Great Plains before the region reaches the Rocky Mountains. The High Plains are located in eastern Montana, southeastern Wyoming, southwestern South Dakota, western Nebraska, eastern Colorado, western Kansas, eastern New Mexico, western Oklahoma, and to just south of the Texas Panhandle.[4] The southern region of the Western High Plains ecology region contains the geological formation known as Llano Estacado which can be seen from a short distance or on satellite maps.[5] From east to west, the High Plains rise in elevation from around 1,800 to 7,000 ft (550 to 2,130 m).[3] -Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft. -Action 5: Finish[1,800 to 7,000 ft] - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] - -{input} -''' - -cot_prompt_feedback_short = '''You are also an advanced reasoning agent that can improve based on self refection. Solve a question answering task with interleaving Thought, Action, Observation steps. Thought can reason about the current situation, and Action can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. -After each observation, provide the next Thought and next Action. Here are some examples: - -Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into? -Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area. -Action 1: Search[Colorado orogeny] -Observation 1: The Colorado orogeny was an episode of mountain building (an orogeny) in Colorado and surrounding areas. This took place from 1780 to 1650 million years ago (Mya), during the Paleoproterozoic (Statherian Period). It is recorded in the Colorado orogen, a >500-km-wide belt of oceanic arc rock that extends southward into New Mexico. The Colorado orogeny was likely part of the larger Yavapai orogeny. -Thought 2: It does not mention the eastern sector of the Colorado orogeny. I need to look up eastern sector. -Action 2: Lookup[eastern sector] -Observation 2: (Result 1 / 1) The Colorado orogen, formerly called the Colorado province, is a >500-km-wide belt of oceanic arc rock (1.78\u20131.65 Ga) that extends southward into New Mexico and composes a major part of the Proterozoic provinces of southwestern United States. This transcontinental collisional event occurred during the Paleoproterozoic (Statherian Period).[1] The Wyoming sector of the Colorado orogeny was formerly called the Medicine Bow orogeny. The eastern sector extends into the High Plains and is called the Central Plains orogeny. The boundary between the Colorado orogeny and the Wyoming craton is the Cheyenne belt, a 5-km-wide mylonitic shear zone that verges northward. The Cheyenne belt transects and cuts off the south edge of the older Trans-Hudson orogeny.[2] -Thought 3: The eastern sector of Colorado orogeny extends into the High Plains, so High Plains is the area. I need to search High Plains and find its elevation range. -Action 3: Search[High Plains]Observation 3: High Plains refers to one of two distinct land regions: -Thought 4: I need to search High Plains (United States). -Action 4: Search[High Plains (United States)] -Observation 4: The High Plains are a subregion of the Great Plains, mainly in the Western United States, but also partly in the Midwest states of Nebraska, Kansas, and South Dakota, generally encompassing the western part of the Great Plains before the region reaches the Rocky Mountains. The High Plains are located in eastern Montana, southeastern Wyoming, southwestern South Dakota, western Nebraska, eastern Colorado, western Kansas, eastern New Mexico, western Oklahoma, and to just south of the Texas Panhandle.[4] The southern region of the Western High Plains ecology region contains the geological formation known as Llano Estacado which can be seen from a short distance or on satellite maps.[5] From east to west, the High Plains rise in elevation from around 1,800 to 7,000 ft (550 to 2,130 m).[3] -Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft. -Action 5: Finish[1,800 to 7,000 ft] - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] - -You have attempted to answer the following question before and failed. The following reflection(s) give a plan to avoid failing to answer the question in the same way you did previously. Use them to improve your strategy of correctly answering the given question. - -{trajectories} - -{input} -''' - -cot_prompt_feedback = '''You are also an advanced reasoning agent that can improve based on self refection. Solve a question answering task with interleaving Thought, Action, Observation steps. Thought can reason about the current situation, and Action can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. -After each observation, provide the next Thought and next Action. Here are some examples: - -Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into? -Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area. -Action 1: Search[Colorado orogeny] -Observation 1: The Colorado orogeny was an episode of mountain building (an orogeny) in Colorado and surrounding areas. This took place from 1780 to 1650 million years ago (Mya), during the Paleoproterozoic (Statherian Period). It is recorded in the Colorado orogen, a >500-km-wide belt of oceanic arc rock that extends southward into New Mexico. The Colorado orogeny was likely part of the larger Yavapai orogeny. -Thought 2: It does not mention the eastern sector of the Colorado orogeny. I need to look up eastern sector. -Action 2: Lookup[eastern sector] -Observation 2: (Result 1 / 1) The Colorado orogen, formerly called the Colorado province, is a >500-km-wide belt of oceanic arc rock (1.78\u20131.65 Ga) that extends southward into New Mexico and composes a major part of the Proterozoic provinces of southwestern United States. This transcontinental collisional event occurred during the Paleoproterozoic (Statherian Period).[1] The Wyoming sector of the Colorado orogeny was formerly called the Medicine Bow orogeny. The eastern sector extends into the High Plains and is called the Central Plains orogeny. The boundary between the Colorado orogeny and the Wyoming craton is the Cheyenne belt, a 5-km-wide mylonitic shear zone that verges northward. The Cheyenne belt transects and cuts off the south edge of the older Trans-Hudson orogeny.[2] -Thought 3: The eastern sector of Colorado orogeny extends into the High Plains, so High Plains is the area. I need to search High Plains and find its elevation range. -Action 3: Search[High Plains]Observation 3: High Plains refers to one of two distinct land regions: -Thought 4: I need to search High Plains (United States). -Action 4: Search[High Plains (United States)] -Observation 4: The High Plains are a subregion of the Great Plains, mainly in the Western United States, but also partly in the Midwest states of Nebraska, Kansas, and South Dakota, generally encompassing the western part of the Great Plains before the region reaches the Rocky Mountains. The High Plains are located in eastern Montana, southeastern Wyoming, southwestern South Dakota, western Nebraska, eastern Colorado, western Kansas, eastern New Mexico, western Oklahoma, and to just south of the Texas Panhandle.[4] The southern region of the Western High Plains ecology region contains the geological formation known as Llano Estacado which can be seen from a short distance or on satellite maps.[5] From east to west, the High Plains rise in elevation from around 1,800 to 7,000 ft (550 to 2,130 m).[3] -Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft. -Action 5: Finish[1,800 to 7,000 ft] - -Question: Guitars for Wounded Warriors is an album that was recorded in the village in which New York county? -Thought 1: I need to search Guitars for Wounded Warriors, then find in which New York county it was recorded. -Action 1: Search[Guitars for Wounded Warriors] -Observation 1: Guitars for Wounded Warriors is the sixth solo studio album by guitarist Brian Tarquin, released in August 2014 by Cleopatra Records. In 2016, it received a Best Compilation Album nomination by the Independent Music Awards. All tracks were recorded at Tarquin's Jungle Room Studios in New Paltz (village), New York. Being moved by the lack of support for returning veterans through his life Tarquin decided to compose a heavy instrumental rock album as a way to show his appreciation to all veterans. So once again he enlisted top notch players to guest with him on the album, Billy Sheehan on Black Hawk, Ron \"Bumblefoot\" Thal and Reb Beach on Taliban Terror, Steve Morse on Freedom, Chris Poland on Charlie Surfs and Hunting, Chuck Loeb on Escape Kabul, Hal Lindes on Sand & Blood, Gary Hoey on 5 Klicks To Hell and Baghdad, Alex De Rosso Dokken on Broken Arrow, and The Flyin' Ryan Brothers on Surgical Strike. The entire album was engineered, produced and composed by Tarquin especially for each one of the guest guitarists. Partial proceeds are donated to the Fisher House Foundation from sales. -Thought 2: Guitars for Wounded Warriors was recorded in New Paltz (village), New York. I need to search New Paltz (village), New York and find which county it is in -Action 2: Search[New Paltz (village), New York] -Observation 2: New Paltz is a village in Ulster County located in the U.S. state of New York. It is approximately 80 miles (130 km) north of New York City and 70 miles (110 km) south of Albany. The population was 7,324 at the 2020 census.[3] -Thought 3: New Paltz (village), New York is in Ulster County, so the answer is Ulster County. -Action 3: Finish[Ulster County] - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] - -You have attempted to answer the following question before and failed, either because your reasoning for the answer was incorrect or the phrasing of your response did not exactly match the answer. The following reflection(s) give a plan to avoid failing to answer the question in the same way you did previously. Use them to improve your strategy of correctly answering the given question. - -{trajectories} -When providing the thought and action for the current trial, that into account these failed trajectories and make sure not to repeat the same mistakes and incorrect answers. - -{input} -''' - -vote_prompt = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by pairs of thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a list of trajectories, decide which trajectory is most promising. Analyze each trajectory in detail and consider possible errors, then conclude in the last line "The best trajectory is {s}", where s the integer id of the trajectory. -''' - -compare_prompt = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by pairs of thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Briefly analyze the correctness of the following two trajectories. Conclude in the last line "The more correct trajectory is 1", "The more correct trajectory is 2", or "The two trajectories are similarly correct". -''' - -score_prompt = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by pairs of thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a trajectory, analyze the following trajectory, then at the last line conclude "Thus the correctness score is {s}", where s is an integer from 1 to 10. -''' - -value_prompt = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by environmental observations about the situation, thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a trajectory, evaluate its correctness and provide your reasoning and analysis in detail. Focus on the latest thought, action, and observation. Incomplete trajectories can be correct if the thoughts and actions so far are correct, even if the answer is not found yet. Do not generate additional thoughts or actions. Then at the last line conclude "Thus the correctness score is {s}", where s is an integer from 1 to 10. - -Question: Which other Mexican Formula One race car driver has held the podium besides the Force India driver born in 1990? -Thought 1: I need to search for a list of Mexican Formula One race car drivers, then eliminate the one who drove for Force India and was born in 1990. -Action 1: Search[Formula One drivers from Mexico] -Observation 1: There have been six Formula One drivers from Mexico who have taken part in races since the championship began in 1950. Sergio P\u00e9rez is the most successful, having won six Grands Prix. Since 2021, P\u00e9rez drives for the Red Bull team.. P\u00e9rez is the only Mexican F1 driver to achieve a pole position. He took his first pole position at the 2022 Saudi Arabian Grand Prix, in his 219th Grand Prix meeting (a record for the most Grands Prix before a pole). -Thought 2: Sergio P\u00e9rez, born in 1990 and drove for Force India, is not the Mexican driver we are looking for. The passage does not give me enough information about other Mexican drivers who have achieved a podium finish. I need to look up more information. -Action 2: Lookup[Formula One drivers from Mexico] -Observation 2: No more results. -Thought 3: The available information doesn't allow me to determine which other Mexican Formula One race car driver, besides Sergio P\u00e9rez, has held the podium. With the search function failing to provide a full list of Mexican Formula One drivers, I need to return different search results. Let's try searching for Mexican Grand Prix winners, as they would have held the podium. -Action 3: Search[Mexican Grand Prix winners] -Observation 3: Could not find Mexican Grand Prix winners. Similar: ['Mexican Grand Prix', 'List of Formula One Grand Prix winners', '1990 Mexican Grand Prix', '2018 Mexican Grand Prix', '2019 Mexican Grand Prix']. -Thought 4: Given the difficulties in finding a direct list of Mexican Grand Prix winners, I should search for the Mexican Grand Prix to get a broader picture of the race's history. This might include winners. -Action 4: Search[Mexican Grand Prix] -Thus the correctness score is 3 - -Question: The football manager who recruited David Beckham managed Manchester United during what timeframe?Question: The football manager who recruited David Beckham managed Manchester United during what timeframe? -Thought 1: I need to search for the football manager who recruited David Beckham, and then find out the timeframe in which he managed Manchester United. -Action 1: Search[David Beckham's football manager] -Observation 1: Could not find David Beckham's football manager. Similar: ['David Beckham', 'Inter Miami CF (redirect from Miami Beckham United)', 'Victoria Beckham', 'The Beckham Experiment', 'Miami Freedom Park']. -Thought 2: I couldn't find the information on David Beckham's football manager. I need to search Manchester United and find out the timeframe of the manager. -Action 2: Search[Manchester United] -Observation 2: Manchester United Football Club, commonly referred to as Man United (often stylised as Man Utd), or simply United, is a professional football club based in Old Trafford, Greater Manchester, England. The club competes in the Premier League, the top division in the English football league system. Nicknamed the Red Devils, they were founded as Newton Heath LYR Football Club in 1878, but changed their name to Manchester United in 1902. After a spell playing in Clayton, Manchester, the club moved to their current stadium, Old Trafford, in 1910.. Domestically, Manchester United have won a record 20 league titles, 12 FA Cups, six League Cups and a record 21 FA Community Shields. -Thought 3: The information about David Beckham's football manager is not available. I will search for Manchester United's managers to find the timeframe. -Action 3: Search[Manchester United managers] -Observation 3: Manchester United Football Club is a professional football club based in Old Trafford, Greater Manchester, England. The club was formed in Newton Heath in 1878 as Newton Heath LYR F.C., and played their first competitive match in October 1886, when they entered the First Round of the 1886–87 FA Cup. The club was renamed Manchester United F.C. in 1902, and moved to Old Trafford in 1910.. From the beginning of the club's official managerial records in 1892 to the start of the 2022–23 season, Manchester United have had 23 full-time managers. -Thought 4: The information about David Beckham's football manager is not available. The question asks for the timeframe when the manager managed Manchester United, so I need to find a list of Manchester United managers. -Action 4: Search[list of Manchester United managers] -Observation 4: Manchester United Football Club is a professional football club based in Old Trafford, Greater Manchester, England. The club was formed in Newton Heath in 1878 as Newton Heath LYR F.C., and played their first competitive match in October 1886, when they entered the First Round of the 1886–87 FA Cup. The club was renamed Manchester United F.C. in 1902, and moved to Old Trafford in 1910.. From the beginning of the club's official managerial records in 1892 to the start of the 2022–23 season, Manchester United have had 23 full-time managers. -Thus the correctness score is 4 - -Question: Which magazine was started first Arthur's Magazine or First for Women? -Thought 1: I need to search Arthur's Magazine and First for Women, and find which was started first. -Action 1: Search[Arthur's Magazine] -Observation 1: Arthur's Magazine (1844\u00e2\u0080\u00931846) was an American literary periodical published in Philadelphia in the 19th century. Edited by Timothy Shay Arthur, it featured work by Edgar A. Poe, J.H. Ingraham, Sarah Josepha Hale, Thomas G. Spear, and others.[1][2] In May 1846 it was merged into Godey's Lady's Book.[3] -Thus the correctness score is 10 - -{input} -''' - -value_prompt_feedback = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by environmental observations about the situation, thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a trajectory, evaluate its correctness and provide your reasoning and analysis in detail. Focus on the latest thought, action, and observation. Incomplete trajectories can be correct if the thoughts and actions so far are correct, even if the answer is not found yet. Do not generate additional thoughts or actions. Then at the last line conclude "Thus the correctness score is {s}", where s is an integer from 1 to 10. - -Question: Which other Mexican Formula One race car driver has held the podium besides the Force India driver born in 1990? -Thought 1: I need to search for a list of Mexican Formula One race car drivers, then eliminate the one who drove for Force India and was born in 1990. -Action 1: Search[Formula One drivers from Mexico] -Observation 1: There have been six Formula One drivers from Mexico who have taken part in races since the championship began in 1950. Sergio P\u00e9rez is the most successful, having won six Grands Prix. Since 2021, P\u00e9rez drives for the Red Bull team.. P\u00e9rez is the only Mexican F1 driver to achieve a pole position. He took his first pole position at the 2022 Saudi Arabian Grand Prix, in his 219th Grand Prix meeting (a record for the most Grands Prix before a pole). -Thought 2: Sergio P\u00e9rez, born in 1990 and drove for Force India, is not the Mexican driver we are looking for. The passage does not give me enough information about other Mexican drivers who have achieved a podium finish. I need to look up more information. -Action 2: Lookup[Formula One drivers from Mexico] -Observation 2: No more results. -Thought 3: The available information doesn't allow me to determine which other Mexican Formula One race car driver, besides Sergio P\u00e9rez, has held the podium. With the search function failing to provide a full list of Mexican Formula One drivers, I need to return different search results. Let's try searching for Mexican Grand Prix winners, as they would have held the podium. -Action 3: Search[Mexican Grand Prix winners] -Observation 3: Could not find Mexican Grand Prix winners. Similar: ['Mexican Grand Prix', 'List of Formula One Grand Prix winners', '1990 Mexican Grand Prix', '2018 Mexican Grand Prix', '2019 Mexican Grand Prix']. -Thought 4: Given the difficulties in finding a direct list of Mexican Grand Prix winners, I should search for the Mexican Grand Prix to get a broader picture of the race's history. This might include winners. -Action 4: Search[Mexican Grand Prix] -Thus the correctness score is 3 - -Question: Which magazine was started first Arthur's Magazine or First for Women? -Thought 1: I need to search Arthur's Magazine and First for Women, and find which was started first. -Action 1: Search[Arthur's Magazine] -Observation 1: Arthur's Magazine (1844\u00e2\u0080\u00931846) was an American literary periodical published in Philadelphia in the 19th century. Edited by Timothy Shay Arthur, it featured work by Edgar A. Poe, J.H. Ingraham, Sarah Josepha Hale, Thomas G. Spear, and others.[1][2] In May 1846 it was merged into Godey's Lady's Book.[3] -Thus the correctness score is 10 - -{trajectories} -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] -Thus the correctness score is 10 - -{input} -''' - -value_prompt_reasoning = '''You are an advanced reasoning agent that can improve based on self refection. Analyze the trajectories of your previous solutions to a question answering task. The trajectories are labeled by environmental observations about the situation, thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a trajectory, evaluate its correctness and provide your reasoning and analysis in detail. Focus on the latest thought, action, and observation. Incomplete trajectories can be correct if the thoughts and actions so far are correct, even if the answer is not found yet. Do not generate additional thoughts or actions. Then at the last line conclude "Thus the correctness score is {s}", where s is an integer from 1 to 10. - -Question: Which other Mexican Formula One race car driver has held the podium besides the Force India driver born in 1990? -Thought 1: I need to search for a list of Mexican Formula One race car drivers, then eliminate the one who drove for Force India and was born in 1990. -Action 1: Search[Formula One drivers from Mexico] -Observation 1: There have been six Formula One drivers from Mexico who have taken part in races since the championship began in 1950. Sergio P\u00e9rez is the most successful, having won six Grands Prix. Since 2021, P\u00e9rez drives for the Red Bull team.. P\u00e9rez is the only Mexican F1 driver to achieve a pole position. He took his first pole position at the 2022 Saudi Arabian Grand Prix, in his 219th Grand Prix meeting (a record for the most Grands Prix before a pole). -Thought 2: Sergio P\u00e9rez, born in 1990 and drove for Force India, is not the Mexican driver we are looking for. The passage does not give me enough information about other Mexican drivers who have achieved a podium finish. I need to look up more information. -Action 2: Lookup[Formula One drivers from Mexico] -Observation 2: No more results. -Thought 3: The available information doesn't allow me to determine which other Mexican Formula One race car driver, besides Sergio P\u00e9rez, has held the podium. With the search function failing to provide a full list of Mexican Formula One drivers, I need to return different search results. Let's try searching for Mexican Grand Prix winners, as they would have held the podium. -Action 3: Search[Mexican Grand Prix winners] -Observation 3: Could not find Mexican Grand Prix winners. Similar: ['Mexican Grand Prix', 'List of Formula One Grand Prix winners', '1990 Mexican Grand Prix', '2018 Mexican Grand Prix', '2019 Mexican Grand Prix']. -Thought 4: Given the difficulties in finding a direct list of Mexican Grand Prix winners, I should search for the Mexican Grand Prix to get a broader picture of the race's history. This might include winners. -Action 4: Search[Mexican Grand Prix] -This trajectory is incorrect as my search should be related to Mexican Formula One race car drivers, not winners of the Mexican Grand Prix, a seperate event. A better search would have been for the List of Formula One Grand Prix winners, as suggested. -Thus the correctness score is 3 - -Question: Which magazine was started first Arthur's Magazine or First for Women? -Thought 1: I need to search Arthur's Magazine and First for Women, and find which was started first. -Action 1: Search[Arthur's Magazine] -Observation 1: Arthur's Magazine (1844\u00e2\u0080\u00931846) was an American literary periodical published in Philadelphia in the 19th century. Edited by Timothy Shay Arthur, it featured work by Edgar A. Poe, J.H. Ingraham, Sarah Josepha Hale, Thomas G. Spear, and others.[1][2] In May 1846 it was merged into Godey's Lady's Book.[3] -This trajectory is correct as it is reasonable to search for the first magazine provided in the question. It is also better to have simple searches corresponding to a single entity, making this the best action. -Thus the correctness score is 10 - -Question: The football manager who recruited David Beckham managed Manchester United during what timeframe?Question: The football manager who recruited David Beckham managed Manchester United during what timeframe? -Thought 1: I need to search for the football manager who recruited David Beckham, and then find out the timeframe in which he managed Manchester United. -Action 1: Search[David Beckham's football manager] -Observation 1: Could not find David Beckham's football manager. Similar: ['David Beckham', 'Inter Miami CF (redirect from Miami Beckham United)', 'Victoria Beckham', 'The Beckham Experiment', 'Miami Freedom Park']. -Thought 2: I couldn't find the information on David Beckham's football manager. I need to search Manchester United and find out the timeframe of the manager. -Action 2: Search[Manchester United] -Observation 2: Manchester United Football Club, commonly referred to as Man United (often stylised as Man Utd), or simply United, is a professional football club based in Old Trafford, Greater Manchester, England. The club competes in the Premier League, the top division in the English football league system. Nicknamed the Red Devils, they were founded as Newton Heath LYR Football Club in 1878, but changed their name to Manchester United in 1902. After a spell playing in Clayton, Manchester, the club moved to their current stadium, Old Trafford, in 1910.. Domestically, Manchester United have won a record 20 league titles, 12 FA Cups, six League Cups and a record 21 FA Community Shields. -Thought 3: The information about David Beckham's football manager is not available. I will search for Manchester United's managers to find the timeframe. -Action 3: Search[Manchester United managers] -Observation 3: Manchester United Football Club is a professional football club based in Old Trafford, Greater Manchester, England. The club was formed in Newton Heath in 1878 as Newton Heath LYR F.C., and played their first competitive match in October 1886, when they entered the First Round of the 1886–87 FA Cup. The club was renamed Manchester United F.C. in 1902, and moved to Old Trafford in 1910.. From the beginning of the club's official managerial records in 1892 to the start of the 2022–23 season, Manchester United have had 23 full-time managers. -Thought 4: The information about David Beckham's football manager is not available. The question asks for the timeframe when the manager managed Manchester United, so I need to find a list of Manchester United managers. -Action 4: Search[list of Manchester United managers] -Observation 4: Manchester United Football Club is a professional football club based in Old Trafford, Greater Manchester, England. The club was formed in Newton Heath in 1878 as Newton Heath LYR F.C., and played their first competitive match in October 1886, when they entered the First Round of the 1886–87 FA Cup. The club was renamed Manchester United F.C. in 1902, and moved to Old Trafford in 1910.. From the beginning of the club's official managerial records in 1892 to the start of the 2022–23 season, Manchester United have had 23 full-time managers. -This trajectory is mostly incorrect as I was not able to retrieve the relevant information to answer the question. The searches are too broad and not relevant to the specific player recruited, David Beckham. The last two actions are also too similar. It would be better to first search for David Beckham and lookup information related to his manager. -Thus the correctness score is 4 - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] -This trajectory is correct as all of my thoughts and actions are correct. It makes sense to search for Milhouse first as it is the central subject of the question. It is also correct to directly look up the relevant information in the article, instead of trying another search. -Thus the correctness score is 10 - -{input} -''' - -value_prompt_reasoning_feedback = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by environmental observations about the situation, thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a trajectory, evaluate its correctness and provide your reasoning and analysis in detail. Focus on the latest thought, action, and observation. Incomplete trajectories can be correct if the thoughts and actions so far are correct, even if the answer is not found yet. Do not generate additional thoughts or actions. Then at the last line conclude "Thus the correctness score is {s}", where s is an integer from 1 to 10. - -Question: Which other Mexican Formula One race car driver has held the podium besides the Force India driver born in 1990? -Thought 1: I need to search for a list of Mexican Formula One race car drivers, then eliminate the one who drove for Force India and was born in 1990. -Action 1: Search[Formula One drivers from Mexico] -Observation 1: There have been six Formula One drivers from Mexico who have taken part in races since the championship began in 1950. Sergio P\u00e9rez is the most successful, having won six Grands Prix. Since 2021, P\u00e9rez drives for the Red Bull team.. P\u00e9rez is the only Mexican F1 driver to achieve a pole position. He took his first pole position at the 2022 Saudi Arabian Grand Prix, in his 219th Grand Prix meeting (a record for the most Grands Prix before a pole). -Thought 2: Sergio P\u00e9rez, born in 1990 and drove for Force India, is not the Mexican driver we are looking for. The passage does not give me enough information about other Mexican drivers who have achieved a podium finish. I need to look up more information. -Action 2: Lookup[Formula One drivers from Mexico] -Observation 2: No more results. -Thought 3: The available information doesn't allow me to determine which other Mexican Formula One race car driver, besides Sergio P\u00e9rez, has held the podium. With the search function failing to provide a full list of Mexican Formula One drivers, I need to return different search results. Let's try searching for Mexican Grand Prix winners, as they would have held the podium. -Action 3: Search[Mexican Grand Prix winners] -Observation 3: Could not find Mexican Grand Prix winners. Similar: ['Mexican Grand Prix', 'List of Formula One Grand Prix winners', '1990 Mexican Grand Prix', '2018 Mexican Grand Prix', '2019 Mexican Grand Prix']. -Thought 4: Given the difficulties in finding a direct list of Mexican Grand Prix winners, I should search for the Mexican Grand Prix to get a broader picture of the race's history. This might include winners. -Action 4: Search[Mexican Grand Prix] -This trajectory is incorrect as my search should be related to Mexican Formula One race car drivers, not winners it is reasonable to checkof the Mexican Grand Prix, a seperate event. A better search would have been for the List of Formula One Grand Prix winners, as suggested. -Thus the correctness score is 3 - -Question: Which magazine was started first Arthur's Magazine or First for Women? -Thought 1: I need to search Arthur's Magazine and First for Women, and find which was started first. -Action 1: Search[Arthur's Magazine] -Observation 1: Arthur's Magazine (1844\u00e2\u0080\u00931846) was an American literary periodical published in Philadelphia in the 19th century. Edited by Timothy Shay Arthur, it featured work by Edgar A. Poe, J.H. Ingraham, Sarah Josepha Hale, Thomas G. Spear, and others.[1][2] In May 1846 it was merged into Godey's Lady's Book.[3] -This trajectory is correct as it is reasonable to search for the first magazine provided in the question. It is also better to have simple searches corresponding to a single entity, making this the best action. -Thus the correctness score is 10 - -Question: The football manager who recruited David Beckham managed Manchester United during what timeframe?Question: The football manager who recruited David Beckham managed Manchester United during what timeframe? -Thought 1: I need to search for the football manager who recruited David Beckham, and then find out the timeframe in which he managed Manchester United. -Action 1: Search[David Beckham's football manager] -Observation 1: Could not find David Beckham's football manager. Similar: ['David Beckham', 'Inter Miami CF (redirect from Miami Beckham United)', 'Victoria Beckham', 'The Beckham Experiment', 'Miami Freedom Park']. -Thought 2: I couldn't find the information on David Beckham's football manager. I need to search Manchester United and find out the timeframe of the manager. -Action 2: Search[Manchester United] -Observation 2: Manchester United Football Club, commonly referred to as Man United (often stylised as Man Utd), or simply United, is a professional football club based in Old Trafford, Greater Manchester, England. The club competes in the Premier League, the top division in the English football league system. Nicknamed the Red Devils, they were founded as Newton Heath LYR Football Club in 1878, but changed their name to Manchester United in 1902. After a spell playing in Clayton, Manchester, the club moved to their current stadium, Old Trafford, in 1910.. Domestically, Manchester United have won a record 20 league titles, 12 FA Cups, six League Cups and a record 21 FA Community Shields. -Thought 3: The information about David Beckham's football manager is not available. I will search for Manchester United's managers to find the timeframe. -Action 3: Search[Manchester United managers] -Observation 3: Manchester United Football Club is a professional football club based in Old Trafford, Greater Manchester, England. The club was formed in Newton Heath in 1878 as Newton Heath LYR F.C., and played their first competitive match in October 1886, when they entered the First Round of the 1886–87 FA Cup. The club was renamed Manchester United F.C. in 1902, and moved to Old Trafford in 1910.. From the beginning of the club's official managerial records in 1892 to the start of the 2022–23 season, Manchester United have had 23 full-time managers. -Thought 4: The information about David Beckham's football manager is not available. The question asks for the timeframe when the manager managed Manchester United, so I need to find a list of Manchester United managers. -Action 4: Search[list of Manchester United managers] -Observation 4: Manchester United Football Club is a professional football club based in Old Trafford, Greater Manchester, England. The club was formed in Newton Heath in 1878 as Newton Heath LYR F.C., and played their first competitive match in October 1886, when they entered the First Round of the 1886–87 FA Cup. The club was renamed Manchester United F.C. in 1902, and moved to Old Trafford in 1910.. From the beginning of the club's official managerial records in 1892 to the start of the 2022–23 season, Manchester United have had 23 full-time managers. -This trajectory is mostly incorrect as I was not able to retrieve the relevant information to answer the question. The searches are too broad and not relevant to the specific player recruited, David Beckham. The last two actions are also too similar. It would be better to first search for David Beckham and lookup information related to his manager. -Thus the correctness score is 4 - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] -This trajectory is correct as all of my thoughts and actions are correct. It makes sense to search for Milhouse first as it is the central subject of the question. It is also correct to directly look up the relevant information in the article, instead of trying another search. -Thus the correctness score is 10 - -{trajectories} - -{input} -''' - -value_prompt_reasoning_feedback_short = '''Analyze the trajectories of a solution to a question answering task. The trajectories are labeled by environmental observations about the situation, thoughts that can reason about the current situation and actions that can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. - -Given a question and a trajectory, evaluate its correctness and provide your reasoning and analysis in detail. Focus on the latest thought, action, and observation. Incomplete trajectories can be correct if the thoughts and actions so far are correct, even if the answer is not found yet. Do not generate additional thoughts or actions. Then at the last line conclude "Thus the correctness score is {s}", where s is an integer from 1 to 10. - -Question: Which other Mexican Formula One race car driver has held the podium besides the Force India driver born in 1990? -Thought 1: I need to search for a list of Mexican Formula One race car drivers, then eliminate the one who drove for Force India and was born in 1990. -Action 1: Search[Formula One drivers from Mexico] -Observation 1: There have been six Formula One drivers from Mexico who have taken part in races since the championship began in 1950. Sergio P\u00e9rez is the most successful, having won six Grands Prix. Since 2021, P\u00e9rez drives for the Red Bull team.. P\u00e9rez is the only Mexican F1 driver to achieve a pole position. He took his first pole position at the 2022 Saudi Arabian Grand Prix, in his 219th Grand Prix meeting (a record for the most Grands Prix before a pole). -Thought 2: Sergio P\u00e9rez, born in 1990 and drove for Force India, is not the Mexican driver we are looking for. The passage does not give me enough information about other Mexican drivers who have achieved a podium finish. I need to look up more information. -Action 2: Lookup[Formula One drivers from Mexico] -Observation 2: No more results. -Thought 3: The available information doesn't allow me to determine which other Mexican Formula One race car driver, besides Sergio P\u00e9rez, has held the podium. With the search function failing to provide a full list of Mexican Formula One drivers, I need to return different search results. Let's try searching for Mexican Grand Prix winners, as they would have held the podium. -Action 3: Search[Mexican Grand Prix winners] -Observation 3: Could not find Mexican Grand Prix winners. Similar: ['Mexican Grand Prix', 'List of Formula One Grand Prix winners', '1990 Mexican Grand Prix', '2018 Mexican Grand Prix', '2019 Mexican Grand Prix']. -Thought 4: Given the difficulties in finding a direct list of Mexican Grand Prix winners, I should search for the Mexican Grand Prix to get a broader picture of the race's history. This might include winners. -Action 4: Search[Mexican Grand Prix] -This trajectory is incorrect as my search should be related to Mexican Formula One race car drivers, not winners of the Mexican Grand Prix, a seperate event. A better search would have been for the List of Formula One Grand Prix winners, as suggested. -Thus the correctness score is 3 - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening. -Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after". -Action 2: Lookup[named after] -Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous. -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] -This trajectory is correct as all of my thoughts and actions are correct. It makes sense to search for Milhouse first as it is the central subject of the question. It is also correct to directly look up the relevant information in the article, instead of trying another search. -Thus the correctness score is 10 - -{trajectories} - -{input} -''' - -rap_prompt = ''' -Solve a question answering task with interleaving Thought and Action steps. Thought can reason about the current situation, and Action can be three types: -(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search. -(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage. -(3) Finish[answer], which returns the answer and finishes the task. -Provide the next Thought and next Action. Here are some examples: - -Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into? -Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area. -Action 1: Search[Colorado orogeny] -Thought 2: Then I need to look up eastern sector. -Action 2: Lookup[eastern sector] -Thought 3: The eastern sector of Colorado orogeny extends into the High Plains, so High Plains is the area. I need to search High Plains and find its elevation range. -Action 3: Search[High Plains] -Thought 4: I need to search High Plains (United States). -Action 4: Search[High Plains (United States)] -Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft. -Action 5: Finish[1,800 to 7,000 ft] - -Question: Guitars for Wounded Warriors is an album that was recorded in the village in which New York county? -Thought 1: I need to search Guitars for Wounded Warriors, then find in which New York county it was recorded. -Action 1: Search[Guitars for Wounded Warriors] -Thought 2: I need to search New Paltz (village), New York and find which county it is in -Action 2: Search[New Paltz (village), New York] -Thought 3: New Paltz (village), New York is in Ulster County, so the answer is Ulster County. -Action 3: Finish[Ulster County] - -Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who? -Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after. -Action 1: Search[Milhouse] -Thought 2: I can look up "named after" for finding the specific individual Milhouse is named after. -Action 2: Lookup[named after] -Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon. -Action 3: Finish[President Richard Nixon] - -{input} -''' \ No newline at end of file diff --git a/src/agentCore.jl b/src/agentCore.jl index 71f3bdb..2860165 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -191,6 +191,8 @@ function _process_message(agent::yiemAgent, msg)::assistantMessage # call executeToolCalls() + # save toolResults to agent._state.messages + # else # break out of while loop diff --git a/src/type.jl b/src/type.jl index 71276fe..897e72d 100644 --- a/src/type.jl +++ b/src/type.jl @@ -295,7 +295,7 @@ function agentState( end -struct toolCall # A tool invocation from the LLM +struct agentToolCall # A tool invocation from the LLM type::String # Always "function" id::String # Unique tool call identifier name::String # Tool name @@ -528,6 +528,15 @@ end + + + + + + + + + -- 2.52.0 From ec28e0ff54695d00ba9e11f15698a810f2866e8a Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 5 Aug 2026 19:09:56 +0700 Subject: [PATCH 025/112] update --- etc.jl | 594 ------------------------------------ src/agentCore.jl | 765 +++++++++++++++++++++++++++++++++++++++++++++++ src/type.jl | 188 ++++++++++++ 3 files changed, 953 insertions(+), 594 deletions(-) diff --git a/etc.jl b/etc.jl index beb9929..4ce0950 100644 --- a/etc.jl +++ b/etc.jl @@ -1,596 +1,2 @@ # ── executeToolCalls() Julia pseudo code ────────────────────────── # Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit - -""" - preparedToolCall(tool, toolCall, args) - -Intermediate state between tool call validation and execution. -This struct is created after `prepareToolCall` succeeds and serves -as the bridge to the execution phase. Keeping the resolved tool, -original call metadata, and validated args together avoids repeated -lookups and allows the execution phase to access all necessary data -without carrying the full context through the call chain. -""" -struct preparedToolCall - tool::agentTool # The resolved tool definition from the context - toolCall::agentToolCall # The original tool call from the assistant - args::any # Validated (and coerced) argument values -end - -""" - immediateOutcome(result, isError) - -A tool call that was resolved without actual execution — either -because the tool was not found, validation failed, or a -`beforeToolCall` hook blocked the call. The result is produced -immediately and emitted as a tool result message. - -Returning an outcome instead of throwing an exception is intentional: -it lets the agent feed the error back to the LLM as a tool result so -the model can recover — for example, by re-issuing a tool call with -corrected arguments after a validation failure. -""" -struct immediateOutcome - result::agentToolResult # The pre-computed tool result - isError::bool # Whether this outcome represents an error -end - -""" - executedOutcome(result, isError) - -A tool call that has been executed by `tool.execute()` but has not -yet been through the `afterToolCall` hook. This intermediate state -is necessary because the hook may mutate the result (content, usage, -termination, error status). Keeping execution and finalization separate -allows the hook to inspect the raw result and decide whether to -transform it or replace it entirely. -""" -struct executedOutcome - result::agentToolResult # The tool's execution result - isError::bool # Whether execution raised an error -end - -""" - finalizedOutcome(toolCall, result, isError) - -The complete outcome of a tool call after both execution and the -`afterToolCall` hook. This is the final form that is used to -construct the `toolResultMessage` emitted to the agent loop. - -The three-phase design (prepare → execute → finalize) exists so that -each phase has a single responsibility: preparation handles validation -and gating, execution performs the actual work, and finalization -applies post-processing hooks. This separation allows the agent loop -to emit `tool_execution_end` events with the finalized data while -keeping each phase independently testable and swappable. -""" -struct finalizedOutcome - toolCall::agentToolCall # The original tool call reference - result::agentToolResult # The final tool result (post-afterToolCall) - isError::bool # Whether the call failed or was blocked -end - -""" - ToolCallBatch(messages, terminate) - -A batch of tool result messages from executing one or more tool calls. -The `terminate` flag indicates whether all tools in the batch requested -termination, which causes the agent loop to stop processing further turns. - -This flag is set by the tool implementation (not the end user) to signal -that the agent should not call the LLM again. Typical use cases: - -- Task completion: a tool like `deploy` or `submit` finishes its work and - returns `terminate: true` so the agent stops instead of asking the LLM - what to do next. -- Unrecoverable error: a tool hits a fatal condition (e.g. database - connection lost, auth token expired) and returns `terminate: true` so - the agent stops with an error message rather than retrying. -- Async handoff: a tool triggers a long-running external operation and - wants the agent to stop now; the external system will later resume the - agent via `continue()`. - -If `terminate` is `false` (default), the agent loop feeds the tool results -back to the LLM for another turn. -""" -struct toolCallBatch - messages::vector{toolResultMessage} # Tool result messages for this batch - terminate::bool # Whether the batch should terminate the loop -end - -""" - createErrorToolResult(msg) - -Builds an `agentToolResult` containing a single text content item -with the provided error message and an empty details dictionary. -Used when a tool call cannot be executed due to errors. - -Returning a result instead of throwing ensures that errors at any -point in the tool call pipeline are fed back to the LLM as a tool -result message. This allows the model to see the error and decide -whether to retry, re-issue the call with different arguments, or -report failure to the user. -""" -function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], dict{any,any}()) -end - -""" - createToolResultMessage(f) - -Constructs a `toolResultMessage` from a `finalizedOutcome`. -Normalizes missing content to an empty array and includes -the `addedToolNames` field only when the tool dynamically -registered new tools during execution. - -This conversion is necessary because the tool result is an -`agentToolResult` used by tool implementations, while the agent -loop consumes `toolResultMessage` objects that become part of the -conversation history. The message format includes metadata like -timestamp and tool call ID that the raw result does not carry, -and it is the object emitted via `message_start`/`message_end` -events so the LLM receives the result as a proper assistant/user -message in the context window. -""" -function createToolResultMessage(f::finalizedOutcome)::toolResultMessage - return toolResultMessage( - "toolResult", f.toolCall.id, f.toolCall.name, - f.result.content, f.result.details, f.result.usage, - get(f.result, :addedToolNames, string[]), f.isError, nowMillis() - ) -end - -""" - shouldTerminate(finalizedCalls) - -Returns `true` only when every finalized call in the batch has -`result.terminate == true`. All tools must agree — if any tool -did not request termination, the agent continues. This prevents -a single tool that happens to set `terminate: true` (e.g. for -metadata purposes) from accidentally stopping the agent when -other tools in the batch did not intend to terminate. -""" -function shouldTerminate(batches::vector{finalizedOutcome})::bool - return !isempty(batches) && all(b -> b.result.terminate, batches) -end - -""" - prepareToolCallArguments(tool, toolCall) - -Calls the tool's optional `prepareArguments` hook to transform -the raw argument values from the LLM before schema validation. -If the tool has no hook or the hook returns the same object -reference, the original call is returned unchanged. - -This hook allows tools to normalize arguments that the LLM may -have produced in a non-standard format — for example, converting -a date string to a timestamp, expanding a short file path to an -absolute path, or normalizing casing. It runs before schema -validation so the validator sees the normalized form rather than -raw LLM output. -""" -function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall - if tool.prepareArguments === nothing - return toolCall - end - prepared = tool.prepareArguments(toolCall.arguments) - if prepared == toolCall.arguments - return toolCall - end - return merge(toolCall, dict(:arguments => prepared)) -end - -""" - prepareToolCall(context, assistantMsg, toolCall, config, signal) - -Resolves the tool by name, prepares and validates its arguments, -and runs the `beforeToolCall` hook. Returns a `preparedToolCall` -if successful or an `immediateOutcome` if the tool is not found, -validation fails, the hook blocks execution, or the signal is -aborted. Errors during preparation are caught and returned as -immediate error outcomes so the agent loop can feed them back -to the model. - -The key design decision here is that preparation never throws. -Every failure path returns an `immediateOutcome` with an error -result. This ensures the agent loop always receives a valid tool -result message for every tool call the assistant requested, -regardless of whether preparation succeeded. The LLM can then -use the error message to decide whether to retry with different -arguments or acknowledge the failure. -""" -function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::union{nothing,abortSignal}, -)::union{preparedToolCall,immediateOutcome} - - tool = find(t -> t.name == toolCall.name, context.tools) - if tool === nothing - return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) - end - - try - # 1. prepare arguments (tool-specific transform) - prepared = prepareToolCallArguments(tool, toolCall) - validatedArgs = validateToolArguments(tool, prepared) - - # 2. beforeToolCall hook — can block - if config.beforeToolCall !== nothing - before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal - ) - if signal !== nothing && signal.aborted - return immediateOutcome(createErrorToolResult("Operation aborted"), true) - end - if before !== nothing && before.block - return immediateOutcome( - createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) - end - end - - return preparedToolCall(tool, toolCall, validatedArgs) - catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) - end -end - -# ── per-call execution ────────────────────────────────────────── - -""" - executePreparedToolCall(prep, signal, emit) - -Executes the tool by calling `tool.execute()` with the validated -arguments, the abort signal, and a callback for streaming partial -results. Emits `toolExecutionUpdate` events for each partial -result batch. Waits for all pending update events to settle before -returning. Catches execution errors and returns them as an error -outcome. The `accepting` guard prevents emitting updates after -the call has finished. - -Long-running tools (e.g. file uploads, model training, web scraping) -may take seconds or minutes. The streaming update mechanism allows -UI listeners and other consumers to show progress in real time rather -than waiting for the entire call to complete. The `accepting` guard -ensures that if the tool's execute function yields after emitting -updates but before returning, no duplicate or stale updates are -emitted after the result has already been captured. -""" -function executePreparedToolCall( - prep::preparedToolCall, - signal::union{nothing,abortSignal}, - emit::agentEventSink, -)::executedOutcome - - updateEvents = promise[] - accepting = true - - try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, - emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - accepting = false - wait.(updateEvents) - return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end -end - -# ── per-call finalization ─────────────────────────────────────── - -""" - finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - -Runs the `afterToolCall` hook on the executed result, allowing -the consumer to mutate the result content, details, usage, -termination flag, or error status. Catches errors from the -hook and converts them to error outcomes. Returns a -`finalizedOutcome` that is used to construct the tool result -message. - -The `afterToolCall` hook exists as a post-processing step that -runs after every tool call regardless of success or failure. -Common use cases include: - -- Masking sensitive data from result content before the LLM - sees it (e.g. removing API keys from error messages). -- Normalizing usage tracking data into a consistent format. -- Inspecting the result and deciding to flip `terminate: true` - based on business logic (e.g. "if deployment failed, stop - the agent rather than retrying"). -- Wrapping an error result in a friendlier message for the LLM - to understand. - -If the hook itself throws, the error is caught and the result -becomes an error outcome. This ensures the tool pipeline never -breaks due to a buggy hook. -""" -function finalizeExecutedToolCall( - context::agentContext, - assistantMsg::assistantMessage, - prep::preparedToolCall, - executed::executedOutcome, - config::agentLoopConfig, - signal::union{nothing,abortSignal}, -)::finalizedOutcome - - result = executed.result - isError = executed.isError - - if config.afterToolCall !== nothing - try - after = config.afterToolCall( - afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal - ) - if after !== nothing - result = merge(result, dict(:content=>get(after,:content,result.content), - :details=>get(after,:details,result.details), - :usage=>get(after,:usage,result.usage), - :terminate=>get(after,:terminate,result.terminate))) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true - end - end - - return finalizedOutcome(prep.toolCall, result, isError) -end - -""" - emitToolExecutionEnd(finalized, emit) - -Emits the `toolExecutionEnd` event with the finalized outcome, -signalling to listeners that the tool call has completed. - -This event is part of the tool execution lifecycle: -`tool_execution_start` → (zero or more `tool_execution_update` events) → -`tool_execution_end`. Listeners (such as the TUI or logging systems) -use this lifecycle to track individual tool calls. The event carries -the final result so listeners have all the data they need without -requiring external state lookups. -""" -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::agentEventSink) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) -end - -# ── sequential execution ──────────────────────────────────────── - -""" - executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) - -Executes tool calls one at a time in the order they appear. For each -call: emits `toolExecutionStart`, runs `prepareToolCall`, -then either resolves the immediate outcome or executes/finalizes -the prepared call. Emits `toolExecutionEnd` and creates the tool -result message before proceeding to the next call. Respects the -abort signal — if aborted, remaining calls are skipped. Returns -a batch with `terminate` determined by whether all results set -the termination flag. - -Sequential execution is required when tool calls have implicit -dependencies — for example, a `create_database` tool must complete -before `create_table` can reference it. It is also the safer -default because it prevents race conditions when multiple tools -share state (e.g. writing to the same file or API rate limits). -Use parallel only when you are confident the tools are independent. -""" -function executeToolCallsSequential( - context::agentContext, - assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, - config::agentLoopConfig, - signal::union{nothing,abortSignal}, - emit::agentEventSink, -)::agentToolCallBatch - - finalizedCalls = finalizedOutcome[] - messages = toolResultMessage[] - - for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - else - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - end - - emitToolExecutionEnd(finalized, emit) - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - - if signal !== nothing && signal.aborted - break - end - end - - return toolCallBatch(messages, shouldTerminate(finalizedCalls)) -end - -# ── parallel execution ────────────────────────────────────────── - -""" - executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) - -Prepares all tool calls concurrently and spawns a task for each -prepared call. Immediate outcomes are resolved instantly. Task -entries are collected in order, then `fetch`ed to await all -concurrent executions. Tool result messages are created from -finalized outcomes in order and returned as a batch. Respects -the abort signal — if aborted during preparation, remaining -calls are skipped. Finalization order preserves the original -call order. - -Parallel execution is appropriate when the assistant requests -independent tools — for example, reading multiple files, querying -separate databases, or making independent API calls. It reduces -wall-clock time compared to sequential execution. The tradeoff is -that parallel calls can overwhelm external resources (rate limits, -connection pools, disk I/O). Finalization preserves the original -call order so tool result messages appear in the same order the -assistant requested them, regardless of which call finishes first. -""" -function executeToolCallsParallel( - context::agentContext, - assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, - config::agentLoopConfig, - signal::union{nothing,abortSignal}, - emit::agentEventSink, -)::agentToolCallBatch - - entries = union{finalizedOutcome,task{finalizedOutcome}}[] - - for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - emitToolExecutionEnd(finalized, emit) - push!(entries, finalized) - else - task = task() do - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emitToolExecutionEnd(finalized, emit) - return finalized - end - schedule(task) - push!(entries, task) - end - - if signal !== nothing && signal.aborted - break - end - end - - finalizedCalls = finalizedOutcome[] - for entry in entries - outcome = entry isa task ? fetch(entry) : entry - push!(finalizedCalls, outcome) - end - - messages = toolResultMessage[] - for f in finalizedCalls - push!(messages, createToolResultMessage(f)) - end - - return toolCallBatch(messages, shouldTerminate(finalizedCalls)) -end - - -""" - executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit) - -Dispatches to sequential or parallel execution. Uses sequential mode -when `config.toolExecution == "sequential"` or when any of the -tool calls reference a tool with `executionMode: "sequential"`. -Otherwise uses parallel execution. This is the entry point called -from `streamAssistantResponse` in the agent loop. - -The sequential mode takes priority over parallel because it is the -safe default. If even one tool in a batch is marked sequential, all -tools execute sequentially — this prevents a single dependent tool -from racing with an otherwise independent one. The per-tool -`executionMode` allows fine-grained control (e.g. most tools are -parallel but a specific write tool is sequential), while the config-level -`toolExecution` provides a global override. -""" -function executeToolCalls( - context::agentContext, - assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, - config::agentLoopConfig, - signal::union{nothing,abortSignal}, - emit::agentEventSink, -)::agentToolCallBatch - - hasSequential = any(tc -> - any(t -> t.name == tc.name && get(t.executionMode, "parallel") == "sequential", - context.tools), toolCalls) - - if config.toolExecution == "sequential" || hasSequential - return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) - else - return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) - end -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/agentCore.jl b/src/agentCore.jl index 2860165..2c2cca7 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -213,6 +213,771 @@ function _process_message(agent::yiemAgent, msg)::assistantMessage end +""" + executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit) + +Dispatches to sequential or parallel execution. Uses sequential mode +when `config.toolExecution == "sequential"` or when any of the +tool calls reference a tool with `executionMode: "sequential"`. +Otherwise uses parallel execution. This is the entry point called +from `streamAssistantResponse` in the agent loop. + +The sequential mode takes priority over parallel because it is the +safe default. If even one tool in a batch is marked sequential, all +tools execute sequentially — this prevents a single dependent tool +from racing with an otherwise independent one. The per-tool +`executionMode` allows fine-grained control (e.g. most tools are +parallel but a specific write tool is sequential), while the config-level +`toolExecution` provides a global override. +""" +function executeToolCalls( + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::Function, +)::agentToolCallBatch + + hasSequential = false + for tc in toolCalls + for t in context.tools + if t.name == tc.name && get(t.executionMode, "parallel") == "sequential" + hasSequential = true + break + end + end + if hasSequential + break + end + end + + if config.toolExecution == "sequential" || hasSequential + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) + else + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) + end +end + + + +""" + createErrorToolResult(msg::String) -> agentToolResult + +Builds an `agentToolResult` containing a single text content item +with the provided error message and an empty details dictionary. +Used when a tool call cannot be executed due to errors. + +Returning a result instead of throwing ensures that errors at any +point in the tool call pipeline are fed back to the LLM as a tool +result message. This allows the model to see the error and decide +whether to retry, re-issue the call with different arguments, or +report failure to the user. + +# Arguments +- `msg::String`: The error message to embed in the result + +# Returns +- `agentToolResult`: A result with `content = [textContent("text", msg)]` + +# Examples +```julia +julia> createErrorToolResult("Tool not found") +agentToolResult([textContent("text", "Tool not found")], Dict{Any,Any}()) +``` +""" +function createErrorToolResult(msg::String)::agentToolResult + return agentToolResult([textContent("text", msg)], dict{any,any}()) +end + +""" + createToolResultMessage(f::finalizedOutcome) -> toolResultMessage + +Constructs a `toolResultMessage` from a `finalizedOutcome`. +Normalizes missing content to an empty array and includes the +`addedToolNames` field only when the tool dynamically registered +new tools during execution. + +This conversion is necessary because the tool result is an +`agentToolResult` used by tool implementations, while the agent +loop consumes `toolResultMessage` objects that become part of the +conversation history. The message format includes metadata like +timestamp and tool call ID that the raw result does not carry, and +it is the object emitted via `messageStart`/`messageEnd` events +so the LLM receives the result as a proper assistant/user message +in the context window. + +# Arguments +- `f::finalizedOutcome`: The finalized tool call outcome + +# Returns +- `toolResultMessage`: A message ready for the agent loop context + +# Examples +```julia +julia> outcome = finalizedOutcome(tc, agentToolResult(content, details, usage, false), false); +julia> createToolResultMessage(outcome) +toolResultMessage("toolResult", "call_1", "search_wine", content, details, usage, [], false, 1234567890) +``` +""" +function createToolResultMessage(f::finalizedOutcome)::toolResultMessage + return toolResultMessage( + "toolResult", f.toolCall.id, f.toolCall.name, + f.result.content, f.result.details, f.result.usage, + get(f.result, :addedToolNames, string[]), f.isError, nowMillis() + ) +end + +""" + shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool + +Returns `true` only when every finalized call in the batch has +`result.terminate == true`. All tools must agree — if any tool +did not request termination, the agent continues. This prevents +a single tool that happens to set `terminate: true` (e.g. for +metadata purposes) from accidentally stopping the agent when +other tools in the batch did not intend to terminate. + +# Arguments +- `finalizedCalls`: Vector of finalized tool call outcomes + +# Returns +- `Bool`: `true` if all calls requested termination + +# Examples +```julia +julia> shouldTerminate(finalizedOutcome[]) +false + +julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), false), false) for _ in 1:2]) +false + +julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), true), false) for _ in 1:2]) +true +``` +""" +function shouldTerminate(batches::vector{finalizedOutcome})::bool + return !isempty(batches) && all(b -> b.result.terminate, batches) +end + +""" + prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall) -> agentToolCall + +Calls the tool's optional `prepareArguments` hook to transform the +raw argument values from the LLM before schema validation. If the +tool has no hook or the hook returns the same object reference, the +original call is returned unchanged. + +This hook allows tools to normalize arguments that the LLM may have +produced in a non-standard format — for example, converting a date +string to a timestamp, expanding a short file path to an absolute +path, or normalizing casing. It runs before schema validation so +the validator sees the normalized form rather than raw LLM output. + +# Arguments +- `tool::agentTool`: The tool definition (may have a `prepareArguments` hook) +- `toolCall::agentToolCall`: The raw tool call from the assistant + +# Returns +- `agentToolCall`: The tool call with potentially transformed arguments + +# Examples +```julia +# No prepareArguments hook — returns input unchanged +prepareToolCallArguments(noHookTool, tc) +# => tc # same reference + +# With hook that normalizes arguments +prepareToolCallArguments(normalizeTool, tc) +# => agentToolCall{..., arguments=Dict("date" => 1700000000)} # "2024-01-15" → timestamp +``` +""" +function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall + if tool.prepareArguments === nothing + return toolCall + end + prepared = tool.prepareArguments(toolCall.arguments) + if prepared == toolCall.arguments + return toolCall + end + return merge(toolCall, dict(:arguments => prepared)) +end + +""" + prepareToolCall(context, assistantMsg, toolCall, config, signal) -> + Union{preparedToolCall,immediateOutcome} + +Resolves the tool by name, prepares and validates its arguments, +and runs the `beforeToolCall` hook. Returns a `preparedToolCall` +if successful or an `immediateOutcome` if the tool is not found, +validation fails, the hook blocks execution, or the signal is +aborted. Errors during preparation are caught and returned as +immediate error outcomes so the agent loop can feed them back +to the model. + +The key design decision here is that preparation never throws. +Every failure path returns an `immediateOutcome` with an error +result. This ensures the agent loop always receives a valid tool +result message for every tool call the assistant requested, +regardless of whether preparation succeeded. The LLM can then +use the error message to decide whether to retry with different +arguments or acknowledge the failure. + +# Arguments +- `context::agentContext`: Current agent context with tools and messages +- `assistantMsg::assistantMessage`: The assistant message containing the tool call +- `toolCall::agentToolCall`: The tool call to prepare +- `config::agentLoopConfig`: Loop configuration (may include `beforeToolCall`) +- `signal::Union{Nothing,AbortSignal}`: Optional abort signal + +# Returns +- `preparedToolCall`: If preparation succeeded (tool found, arguments valid, not blocked) +- `immediateOutcome`: If preparation failed (tool missing, invalid args, blocked, aborted) + +# Notes +- Tool lookup is by name via `context.tools` +- Validation uses `validateToolArguments` which coerces types per the tool schema +- The `beforeToolCall` hook can block execution by returning `{ block: true }` + +# Examples +```julia +# Success path +prepareToolCall(context, msg, tc, config, signal) +# => preparedToolCall(tool, tc, validatedArgs) + +# Tool not found +prepareToolCall(context, msg, tcNoMatch, config, signal) +# => immediateOutcome(createErrorToolResult("Tool fake_tool not found"), true) + +# Validation failure +prepareToolCall(context, msg, tcBadArgs, config, signal) +# => immediateOutcome(createErrorToolResult("Validation failed..."), true) + +# Aborted during preparation +prepareToolCall(context, msg, tc, config, abortedSignal) +# => immediateOutcome(createErrorToolResult("Operation aborted"), true) +``` +""" +function prepareToolCall( + context::agentContext, + assistantMsg::assistantMessage, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, +)::union{preparedToolCall,immediateOutcome} + + tool = find(t -> t.name == toolCall.name, context.tools) + if tool === nothing + return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + end + + try + # 1. prepare arguments (tool-specific transform) + prepared = prepareToolCallArguments(tool, toolCall) + validatedArgs = validateToolArguments(tool, prepared) + + # 2. beforeToolCall hook — can block + if config.beforeToolCall !== nothing + before = config.beforeToolCall( + assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + ) + if signal !== nothing && signal.aborted + return immediateOutcome(createErrorToolResult("Operation aborted"), true) + end + if before !== nothing && before.block + return immediateOutcome( + createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) + end + end + + return preparedToolCall(tool, toolCall, validatedArgs) + catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + end +end + +# ── per-call execution ────────────────────────────────────────── + +""" + executePreparedToolCall(prep, signal, emit) -> executedOutcome + +Executes the tool by calling `tool.execute()` with the validated +arguments, the abort signal, and a callback for streaming partial +results. Emits `toolExecutionUpdate` events for each partial +result batch. Waits for all pending update events to settle before +returning. Catches execution errors and returns them as an error +outcome. The `accepting` guard prevents emitting updates after +the call has finished. + +Long-running tools (e.g. file uploads, model training, web scraping) +may take seconds or minutes. The streaming update mechanism allows +UI listeners and other consumers to show progress in real time rather +than waiting for the entire call to complete. The `accepting` guard +ensures that if the tool's execute function yields after emitting +updates but before returning, no duplicate or stale updates are +emitted after the result has already been captured. + +# Arguments +- `prep::preparedToolCall`: The prepared tool call (resolved tool + validated args) +- `signal::Union{Nothing,AbortSignal}`: Optional abort signal +- `emit::Function`: Event emitter for lifecycle events + +# Returns +- `executedOutcome`: The execution result and whether it was an error + +# Examples +```julia +# Successful execution +executePreparedToolCall(prep, nothing, emit) +# => executedOutcome(agentToolResult([textContent("text", "Found 3 wines")], dict{any,any}(), dict{any,any}()), false) + +# Execution error +executePreparedToolCall(prep, nothing, emit) +# => executedOutcome(createErrorToolResult("Connection timeout"), true) +``` +""" +function executePreparedToolCall( + prep::preparedToolCall, + signal::union{nothing,abortSignal}, + emit::Function, +)::executedOutcome + + updateEvents = promise[] + accepting = true + + try + result = prep.tool.execute( + prep.toolCall.id, prep.args, signal, + partialResult -> begin + if accepting + push!(updateEvents, + emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + prep.toolCall.arguments, partialResult))) + end + end + ) + accepting = false + wait.(updateEvents) + return executedOutcome(result, false) + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end +end + +# ── per-call finalization ─────────────────────────────────────── + +""" + finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) -> + finalizedOutcome + +Runs the `afterToolCall` hook on the executed result, allowing +the consumer to mutate the result content, details, usage, +termination flag, or error status. Catches errors from the +hook and converts them to error outcomes. Returns a +`finalizedOutcome` that is used to construct the tool result +message. + +The `afterToolCall` hook exists as a post-processing step that +runs after every tool call regardless of success or failure. +Common use cases include: + + - Masking sensitive data from result content before the LLM + sees it (e.g. removing API keys from error messages). + - Normalizing usage tracking data into a consistent format. + - Inspecting the result and deciding to flip `terminate: true` + based on business logic (e.g. "if deployment failed, stop + the agent rather than retrying"). + - Wrapping an error result in a friendlier message for the LLM + to understand. + +If the hook itself throws, the error is caught and the result +becomes an error outcome. This ensures the tool pipeline never +breaks due to a buggy hook. + +# Arguments +- `context::agentContext`: Current agent context +- `assistantMsg::assistantMessage`: The assistant message that made the tool call +- `prep::preparedToolCall`: The originally prepared tool call +- `executed::executedOutcome`: The raw execution result +- `config::agentLoopConfig`: Loop configuration (may include `afterToolCall`) +- `signal::Union{Nothing,AbortSignal}`: Optional abort signal + +# Returns +- `finalizedOutcome`: The finalized outcome ready for message construction + +# Examples +```julia +# No afterToolCall hook — returns executed result unchanged +finalizeExecutedToolCall(context, msg, prep, execOk, config, nothing) +# => finalizedOutcome(tc, execOk.result, false) + +# afterToolCall masks sensitive data +finalizeExecutedToolCall(context, msg, prep, execOk, configWithHook, nothing) +# => finalizedOutcome(tc, maskedResult, false) + +# afterToolCall flips terminate based on business logic +finalizeExecutedToolCall(context, msg, prep, execFail, config, nothing) +# => finalizedOutcome(tc, {terminate: true}, true) +``` +""" +function finalizeExecutedToolCall( + context::agentContext, + assistantMsg::assistantMessage, + prep::preparedToolCall, + executed::executedOutcome, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, +)::finalizedOutcome + + result = executed.result + isError = executed.isError + + if config.afterToolCall !== nothing + try + after = config.afterToolCall( + afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + ) + if after !== nothing + result = merge(result, dict(:content=>get(after,:content,result.content), + :details=>get(after,:details,result.details), + :usage=>get(after,:usage,result.usage), + :terminate=>get(after,:terminate,result.terminate))) + isError = get(after, :isError, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true + end + end + + return finalizedOutcome(prep.toolCall, result, isError) +end + +""" + emitToolExecutionEnd(finalized, emit) + +Emits the `toolExecutionEnd` event with the finalized outcome, +signalling to listeners that the tool call has completed. + +This event is part of the tool execution lifecycle: +`toolExecutionStart` → (zero or more `toolExecutionUpdate` events) → +`toolExecutionEnd`. Listeners (such as the TUI or logging systems) +use this lifecycle to track individual tool calls. The event carries +the final result so listeners have all the data they need without +requiring external state lookups. + +# Arguments +- `finalized::finalizedOutcome`: The finalized outcome to report +- `emit::Function`: Event emitter + +# Notes +- Part of a three-event lifecycle per tool call +- Carries the complete result so listeners need no external lookups + +# Examples +```julia +# Emits a single event; returns nothing +emitToolExecutionEnd(finalized, emit) +# (emit receives toolExecEndEvent("call_1", "search_wine", result, false)) +``` +""" +function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) +end + +# ── sequential execution ──────────────────────────────────────── + +""" + executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) -> + agentToolCallBatch + +Executes tool calls one at a time in the order they appear. For each +call: emits `toolExecutionStart`, runs `prepareToolCall`, +then either resolves the immediate outcome or executes/finalizes +the prepared call. Emits `toolExecutionEnd` and creates the tool +result message before proceeding to the next call. Respects the +abort signal — if aborted, remaining calls are skipped. Returns +a batch with `terminate` determined by whether all results set +the termination flag. + +Sequential execution is required when tool calls have implicit +dependencies — for example, a `create_database` tool must complete +before `create_table` can reference it. It is also the safer +default because it prevents race conditions when multiple tools +share state (e.g. writing to the same file or API rate limits). +Use parallel only when you are confident the tools are independent. + +# Arguments +- `context::agentContext`: Current agent context +- `assistantMsg::assistantMessage`: The assistant message containing tool calls +- `toolCalls::Vector{agentToolCall}`: Tool calls to execute (ordered) +- `config::agentLoopConfig`: Loop configuration +- `signal::Union{Nothing,AbortSignal}`: Optional abort signal +- `emit::Function`: Event emitter + +# Returns +- `agentToolCallBatch`: Result messages and termination flag + +# Notes +- Calls execute strictly in order; each completes fully before the next begins +- Aborting during one call skips all remaining calls +- If any call returns `terminate: true`, it is included in the batch but does + not force termination unless all calls do + +# Examples +```julia +# Two independent reads — both succeed +executeToolCallsSequential(ctx, msg, [readTc, readTc2], config, nothing, emit) +# => agentToolCallBatch([result1, result2], false) + +# One tool fails, next is skipped due to abort +executeToolCallsSequential(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit) +# => agentToolCallBatch([result1], false) # tc2 failed, tc3 skipped + +# All tools request termination +executeToolCallsSequential(ctx, msg, [deployTc], config, nothing, emit) +# => agentToolCallBatch([deployResult], true) +``` +""" +function executeToolCallsSequential( + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::Function, +)::agentToolCallBatch + + finalizedCalls = finalizedOutcome[] + messages = toolResultMessage[] + + for tc in toolCalls + emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + + prep = prepareToolCall(context, assistantMsg, tc, config, signal) + + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + else + executed = executePreparedToolCall(prep, signal, emit) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + end + + emitToolExecutionEnd(finalized, emit) + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + + if signal !== nothing && signal.aborted + break + end + end + + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) +end + +# ── parallel execution ────────────────────────────────────────── + +""" + executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) -> + agentToolCallBatch + +Prepares all tool calls concurrently and spawns a task for each +prepared call. Immediate outcomes are resolved instantly. Task +entries are collected in order, then `fetch`ed to await all +concurrent executions. Tool result messages are created from +finalized outcomes in order and returned as a batch. Respects +the abort signal — if aborted during preparation, remaining +calls are skipped. Finalization order preserves the original +call order. + +Parallel execution is appropriate when the assistant requests +independent tools — for example, reading multiple files, querying +separate databases, or making independent API calls. It reduces +wall-clock time compared to sequential execution. The tradeoff is +that parallel calls can overwhelm external resources (rate limits, +connection pools, disk I/O). Finalization preserves the original +call order so tool result messages appear in the same order the +assistant requested them, regardless of which call finishes first. + +# Arguments +- `context::agentContext`: Current agent context +- `assistantMsg::assistantMessage`: The assistant message containing tool calls +- `toolCalls::Vector{agentToolCall}`: Tool calls to execute (order preserved in output) +- `config::agentLoopConfig`: Loop configuration +- `signal::Union{Nothing,AbortSignal}`: Optional abort signal +- `emit::Function`: Event emitter + +# Returns +- `agentToolCallBatch`: Result messages (in original call order) and termination flag + +# Notes +- All tool calls are prepared before any execution begins +- Execution tasks run concurrently; `fetch` waits for completion in order +- Immediate outcomes (errors/blocks) resolve instantly without spawning tasks +- Aborting during preparation skips remaining preparations but does not + cancel tasks already running + +# Examples +```julia +# Three independent reads — all succeed, results ordered by original call order +executeToolCallsParallel(ctx, msg, [readA, readB, readC], config, nothing, emit) +# => agentToolCallBatch([resultA, resultB, resultC], false) + +# Mix of immediate error and concurrent success +executeToolCallsParallel(ctx, msg, [badTc, goodTc], config, nothing, emit) +# => agentToolCallBatch([errorResult, goodResult], false) + +# Abort during preparation +executeToolCallsParallel(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit) +# => agentToolCallBatch([...], false) # only prepared calls complete +``` +""" +function executeToolCallsParallel( + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::Function, +)::agentToolCallBatch + + entries = union{finalizedOutcome,task{finalizedOutcome}}[] + + for tc in toolCalls + emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + + prep = prepareToolCall(context, assistantMsg, tc, config, signal) + + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + emitToolExecutionEnd(finalized, emit) + push!(entries, finalized) + else + task = task() do + executed = executePreparedToolCall(prep, signal, emit) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + emitToolExecutionEnd(finalized, emit) + return finalized + end + schedule(task) + push!(entries, task) + end + + if signal !== nothing && signal.aborted + break + end + end + + finalizedCalls = finalizedOutcome[] + for entry in entries + outcome = entry isa task ? fetch(entry) : entry + push!(finalizedCalls, outcome) + end + + messages = toolResultMessage[] + for f in finalizedCalls + push!(messages, createToolResultMessage(f)) + end + + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) +end + + +""" + executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit) -> + agentToolCallBatch + +Dispatches to sequential or parallel execution. Uses sequential mode +when `config.toolExecution == "sequential"` or when any of the +tool calls reference a tool with `executionMode: "sequential"`. +Otherwise uses parallel execution. This is the entry point called +from `streamAssistantResponse` in the agent loop. + +The sequential mode takes priority over parallel because it is the +safe default. If even one tool in a batch is marked sequential, all +tools execute sequentially — this prevents a single dependent tool +from racing with an otherwise independent one. The per-tool +`executionMode` allows fine-grained control (e.g. most tools are +parallel but a specific write tool is sequential), while the config-level +`toolExecution` provides a global override. + +# Arguments +- `context::agentContext`: Current agent context (used for per-tool `executionMode` lookup) +- `assistantMsg::assistantMessage`: The assistant message containing tool calls +- `toolCalls::Vector{agentToolCall}`: Tool calls to execute +- `config::agentLoopConfig`: Loop configuration (`toolExecution` mode) +- `signal::Union{Nothing,AbortSignal}`: Optional abort signal +- `emit::Function`: Event emitter + +# Returns +- `agentToolCallBatch`: The result batch from the selected execution strategy + +# Notes +- Per-tool `executionMode` is checked against `context.tools` for each tool call +- If any tool is sequential, the entire batch runs sequentially +- `config.toolExecution` can override all per-tool settings globally + +# Examples +```julia +# Parallel dispatch — no sequential tools in batch +executeToolCalls(ctx, msg, [searchTc, fetchTc], configParallel, nothing, emit) +# => agentToolCallBatch(results, false) # parallel execution + +# Sequential fallback — one tool is marked sequential +executeToolCalls(ctx, msg, [searchTc, writeTc], configParallel, nothing, emit) +# => agentToolCallBatch(results, false) # sequential because writeTc is sequential + +# Global override — config forces sequential regardless of per-tool settings +executeToolCalls(ctx, msg, [searchTc, fetchTc], configSequential, nothing, emit) +# => agentToolCallBatch(results, false) # sequential because config says so +``` +""" +function executeToolCalls( + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::vector{agentToolCall}, + config::agentLoopConfig, + signal::union{nothing,abortSignal}, + emit::Function, +)::agentToolCallBatch + + hasSequential = false + for tc in toolCalls + for t in context.tools + if t.name == tc.name && get(t.executionMode, "parallel") == "sequential" + hasSequential = true + break + end + end + if hasSequential + break + end + end + + if config.toolExecution == "sequential" || hasSequential + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) + else + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) + end +end + + + + + + + + + + + + + + + diff --git a/src/type.jl b/src/type.jl index 897e72d..99cff35 100644 --- a/src/type.jl +++ b/src/type.jl @@ -396,6 +396,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function end """ @@ -419,6 +420,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) +- `agentEventSink::Function`: Callback to receive agent events # Returns - A new `yiemAgent` instance with an active background task @@ -444,6 +446,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, + agentEventSink::Function, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -467,6 +470,7 @@ function yiemAgent( sessionId, maxRetryDelayMs, parallelToolExecute, + agentEventSink, ) # Spawn the background loop and attach it @@ -477,6 +481,190 @@ end +""" + preparedToolCall(tool, toolCall, args) + +Intermediate state between tool call validation and execution. +Created after `prepareToolCall` succeeds; serves as the bridge to +the execution phase. Keeping the resolved tool, original call +metadata, and validated args together avoids repeated lookups and +allows the execution phase to access all necessary data without +carrying the full context through the call chain. + +# Fields +- `tool::agentTool`: The resolved tool definition from the context +- `toolCall::agentToolCall`: The original tool call from the assistant +- `args::any`: Validated (and coerced) argument values + +# Examples +```julia +# After prepareToolCall succeeds, the agent holds a preparedToolCall +prep = preparedToolCall( + tool, # agentTool found in context.tools + toolCall, # {id: "call_1", name: "search_wine", arguments: "{\"query\": \"red wine\"}"} + validatedArgs # Dict("query" => "red wine") +) +``` +""" +struct preparedToolCall + tool::agentTool # The resolved tool definition from the context + toolCall::agentToolCall # The original tool call from the assistant + args::any # Validated (and coerced) argument values +end + +""" + immediateOutcome(result, isError) + +A tool call that was resolved without actual execution — either +because the tool was not found, validation failed, or a +`beforeToolCall` hook blocked the call. The result is produced +immediately and emitted as a tool result message. + +Returning an outcome instead of throwing an exception is intentional: +it lets the agent feed the error back to the LLM as a tool result so +the model can recover — for example, by re-issuing a tool call with +corrected arguments after a validation failure. + +# Fields +- `result::agentToolResult`: The pre-computed tool result +- `isError::bool`: Whether this outcome represents an error + +# Examples +```julia +# Tool not found — immediate error +immediateOutcome( + createErrorToolResult("Tool search_wine not found"), + true +) + +# beforeToolCall hook blocked execution +immediateOutcome( + createErrorToolResult("Tool execution was blocked"), + true +) +``` +""" +struct immediateOutcome + result::agentToolResult # The pre-computed tool result + isError::bool # Whether this outcome represents an error +end + +""" + executedOutcome(result, isError) + +A tool call that has been executed by `tool.execute()` but has not +yet been through the `afterToolCall` hook. This intermediate state +is necessary because the hook may mutate the result (content, usage, +termination, error status). Keeping execution and finalization +separate allows the hook to inspect the raw result and decide +whether to transform it or replace it entirely. + +# Fields +- `result::agentToolResult`: The tool's execution result +- `isError::bool`: Whether execution raised an error + +# Examples +```julia +# Successful execution +executedOutcome( + agentToolResult([textContent("text", "Found 3 wines")], dict{any,any}(), dict{any,any}()), + false +) + +# Execution error +executedOutcome( + createErrorToolResult("Connection timeout"), + true +) +``` +""" +struct executedOutcome + result::agentToolResult # The tool's execution result + isError::bool # Whether execution raised an error +end + +""" + finalizedOutcome(toolCall, result, isError) + +The complete outcome of a tool call after both execution and the +`afterToolCall` hook. This is the final form used to construct +the `toolResultMessage` emitted to the agent loop. + +The three-phase design (prepare → execute → finalize) exists so +that each phase has a single responsibility: preparation handles +validation and gating, execution performs the actual work, and +finalization applies post-processing hooks. This separation allows +the agent loop to emit `toolExecutionEnd` events with the +finalized data while keeping each phase independently testable +and swappable. + +# Fields +- `toolCall::agentToolCall`: The original tool call reference +- `result::agentToolResult`: The final tool result (post-afterToolCall) +- `isError::bool`: Whether the call failed or was blocked + +# Examples +```julia +# Normal successful finalization +finalizedOutcome(tc, agentToolResult(content, details, usage, false), false) + +# afterToolCall mutated result and set terminate +finalizedOutcome(tc, agentToolResult(content, details, usage, true), false) +``` +""" +struct finalizedOutcome + toolCall::agentToolCall # The original tool call reference + result::agentToolResult # The final tool result (post-afterToolCall) + isError::bool # Whether the call failed or was blocked +end + +""" + agentToolCallBatch(messages, terminate) + +A batch of tool result messages from executing one or more tool calls. +The `terminate` flag indicates whether all tools in the batch +requested termination, which causes the agent loop to stop +processing further turns. + +This flag is set by the tool implementation (not the end user) to +signal that the agent should not call the LLM again. Typical use +cases: + + - Task completion: a tool like `deploy` or `submit` finishes its + work and returns `terminate: true` so the agent stops instead + of asking the LLM what to do next. + - Unrecoverable error: a tool hits a fatal condition (e.g. + database connection lost, auth token expired) and returns + `terminate: true` so the agent stops with an error message + rather than retrying. + - Async handoff: a tool triggers a long-running external + operation and wants the agent to stop now; the external system + will later resume the agent via `continue()`. + +If `terminate` is `false` (default), the agent loop feeds the tool +results back to the LLM for another turn. + +# Fields +- `messages::Vector{toolResultMessage}`: Tool result messages for this batch +- `terminate::Bool`: Whether the batch should terminate the loop + +# Examples +```julia +# Batch of 3 tool results, no termination +agentToolCallBatch(resultMessages, false) + +# All tools requested termination +agentToolCallBatch(resultMessages, true) + +# Empty batch — terminate is false regardless +agentToolCallBatch(toolResultMessage[], false) +``` +""" +struct agentToolCallBatch + messages::vector{toolResultMessage} # Tool result messages for this batch + terminate::bool # Whether the batch should terminate the loop +end + -- 2.52.0 From 4cb01c71bb9cfecaa9ff0c995cd7eb7639f8279b Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 5 Aug 2026 19:33:40 +0700 Subject: [PATCH 026/112] update --- src/agentCore.jl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 2c2cca7..c830f7e 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -81,7 +81,7 @@ function _agent_loop(agent::yiemAgent) while msg === nothing if isready(agent.inputChannel) - # message will be taken then process in _process_message() + # message will be taken in _process_message() msg = fetch!(agent.inputChannel) else yield() @@ -109,7 +109,7 @@ function _agent_loop(agent::yiemAgent) # make active if agent._state.activeRun == false # Dispatch message through the processing pipeline - processing_task = @spawn _process_message(agent, msg) + processing_task = @spawn _process_message(agent) agent._state.activeRun = true end @@ -173,7 +173,7 @@ should be implemented. Currently a placeholder that echoes back the received mes julia> # Currently returns a placeholder echo response ``` """ -function _process_message(agent::yiemAgent, msg)::assistantMessage +function _process_message(agent::yiemAgent)::assistantMessage # WORKING # loop until llmCall() response didn't use tool calls @@ -187,13 +187,13 @@ function _process_message(agent::yiemAgent, msg)::assistantMessage # Call llmCall() (blocking — the task waits here) - # if LLM use tool calls + # if (LLM use tool calls) # call executeToolCalls() # save toolResults to agent._state.messages - # else + # else (LLM not use tool calls) # break out of while loop end -- 2.52.0 From 8a2da0f5c390d02f1381430ea5a09ef95e0a01d4 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 15:32:56 +0700 Subject: [PATCH 027/112] update --- src/YiemAgent.jl | 4 +- src/agentCore.jl | 186 ++++++------ src/type.jl | 214 +++++++++++--- src/util.jl | 505 -------------------------------- src/utils.jl | 179 +++++++++++ {src => src_OLD}/llmfunction.jl | 0 6 files changed, 458 insertions(+), 630 deletions(-) delete mode 100644 src/util.jl create mode 100644 src/utils.jl rename {src => src_OLD}/llmfunction.jl (100%) diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 822e9fb..e021f3c 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -10,8 +10,8 @@ module YiemAgent include("type.jl") using .type - include("util.jl") - using .util + include("utils.jl") + using .utils include("llmfunction.jl") using .llmfunction diff --git a/src/agentCore.jl b/src/agentCore.jl index c830f7e..dd27893 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,11 +1,11 @@ module agentCore -# export prompt +export _agent_loop using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Serde using GeneralUtils -using ..type, ..util, ..llmfunction +using ..type, ..utils, ..llmfunction # ---------------------------------------------- 100 --------------------------------------------- # @@ -74,7 +74,6 @@ function _agent_loop(agent::yiemAgent) agent.followUpChannel -> nothing """ - while true result = nothing msg = nothing @@ -106,7 +105,7 @@ function _agent_loop(agent::yiemAgent) break end - # make active + # start _process_message loop if agent._state.activeRun == false # Dispatch message through the processing pipeline processing_task = @spawn _process_message(agent) @@ -122,8 +121,8 @@ function _agent_loop(agent::yiemAgent) put!(agent.inputChannel, followMsg) end end - continue # continue to process user message in the next loop + elseif typeof(processing_task) == Task && istaskdone(processing_task) == true # if agent runs is done but followUpChannel has messages, discard all message in it. # when agent work is done it should not accept follow up msg. @@ -135,8 +134,8 @@ function _agent_loop(agent::yiemAgent) end result = fetch(processing_task) put!(agent.outputChannel, result) - agent._state.activeRun = false - processing_task = nothing + agent._state.activeRun = false # reset + processing_task = nothing # reset end end catch e @@ -163,7 +162,7 @@ should be implemented. Currently a placeholder that echoes back the received mes - Implement the full processing pipeline: 1. Add `msg` to `agent._state.messages` 2. Call `agent.formatMsgForLLM(agent._state)` 1 to format for LLM - 3. If `agent.preprocessContext` is set, call it on the formatted messages + 3. If `agent.prepareContext` is set, call it on the formatted messages 4. Call the LLM (blocking — the task waits here) 5. If agent has tools, handle tool calls in a loop 6. Build `assistantMessage` and return it @@ -174,93 +173,116 @@ julia> # Currently returns a placeholder echo response ``` """ function _process_message(agent::yiemAgent)::assistantMessage - # WORKING - # loop until llmCall() response didn't use tool calls - while - # take every messages from agent.inputChannel, convert them into userMessage - # and add them to agent._state.messages + final_response = nothing + while true + # call agent.prepareContext() + preparedContext = agent.prepareContext(agent._state) - # call agent.preprocessContext() - - # Call agent.formatMsgForLLM(agent._state) to format for LLM + #WORKING Call agent.formatMsgForLLM(agent._state) to format for LLM + formatted_messages = agent.formatMsgForLLM(preparedContext) # Call llmCall() (blocking — the task waits here) + response = agent.llmCall(formatted_messages) - # if (LLM use tool calls) + # Check if LLM used tool calls (inspect content for tool_call blocks) + has_tool_calls = false + tool_call_list = agentToolCall[] + + for content_block in response.content + if content_block isa Dict + if get(content_block, :type, "") == "tool_calls" + has_tool_calls = true + for tc_data in get(content_block, :tool_calls, []) + tc = agentToolCall( + type="function", + id=get(tc_data, :id, string(uuid4())), + name=get(tc_data, :function, Dict{String,Any}())[:name], + arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], + ) + push!(tool_call_list, tc) + end + elseif get(content_block, :type, "") == "tool_call" + has_tool_calls = true + tc_data = content_block + tc = agentToolCall( + type="function", + id=get(tc_data, :id, string(uuid4())), + name=get(tc_data, :name, ""), + arguments=get(tc_data, :arguments, Dict{String,Any}()), + ) + push!(tool_call_list, tc) + end + end + end + + if has_tool_calls && length(tool_call_list) > 0 + # Build context and config for executeToolCalls + context = agentContext( + agent._state.systemPrompt, + agent._state.messages, + agent._state.tools, + ) + + config = agentLoopConfig( + agent._state.tools, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute ? "parallel" : "sequential", + ) + + signal = nothing + emit = agent.agentEventSink # call executeToolCalls() + batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) # save toolResults to agent._state.messages + for tool_result in batch.messages + push!(agent._state.messages, tool_result) + end - # else (LLM not use tool calls) - # break out of while loop - + if batch.terminate + # If batch requested termination, build a final response + final_content = [textContent("Tool execution completed.")] + for tool_result in batch.messages + for content_block in tool_result.content + if content_block isa textContent + append!(final_content, [content_block]) + elseif content_block isa Dict + if haskey(content_block, :text) + push!(final_content, textContent(content_block[:text])) + end + end + end + end + final_response = assistantMessage( + role="assistant", + content=final_content, + api=response.api, + model=response.model, + usage=response.usage, + stopReason="tool_use_terminated", + errorMessage=if any(x -> x.isError, batch.messages) + "One or more tool calls failed" + else + nothing + end, + timestamp=now(), + ) + break + end + else + # LLM did not use tool calls — this is the final response + final_response = response + break + end end - # Build assistantMessage and return it - - # Placeholder: echo back the message as a simple response - @warn "TODO: implement _process_message" - return assistantMessage( - role="assistant", - content=[textContent("Received: $(msg)")], - api="", model="", usage=nothing, - stopReason="end_turn", - errorMessage=nothing, - timestamp=now(), - ) + return final_response end -""" - executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit) - -Dispatches to sequential or parallel execution. Uses sequential mode -when `config.toolExecution == "sequential"` or when any of the -tool calls reference a tool with `executionMode: "sequential"`. -Otherwise uses parallel execution. This is the entry point called -from `streamAssistantResponse` in the agent loop. - -The sequential mode takes priority over parallel because it is the -safe default. If even one tool in a batch is marked sequential, all -tools execute sequentially — this prevents a single dependent tool -from racing with an otherwise independent one. The per-tool -`executionMode` allows fine-grained control (e.g. most tools are -parallel but a specific write tool is sequential), while the config-level -`toolExecution` provides a global override. -""" -function executeToolCalls( - context::agentContext, - assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, - config::agentLoopConfig, - signal::union{nothing,abortSignal}, - emit::Function, -)::agentToolCallBatch - - hasSequential = false - for tc in toolCalls - for t in context.tools - if t.name == tc.name && get(t.executionMode, "parallel") == "sequential" - hasSequential = true - break - end - end - if hasSequential - break - end - end - - if config.toolExecution == "sequential" || hasSequential - return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) - else - return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) - end -end - - - """ createErrorToolResult(msg::String) -> agentToolResult @@ -1040,10 +1062,6 @@ end - - - - diff --git a/src/type.jl b/src/type.jl index 99cff35..c7cb570 100644 --- a/src/type.jl +++ b/src/type.jl @@ -1,15 +1,11 @@ module type - export agent, sommelier, companion, virtualcustomer, agentContext, yiemAgent, + export agentContext, yiemAgent, run_agent, take_response, follow_up, stop_agent using Dates, UUIDs, DataStructures, JSON, NATS using GeneralUtils -# ============================================================================ -# Simple type aliases / definitions -# ============================================================================ - const Timestamp = DateTime struct Usage @@ -17,12 +13,10 @@ struct Usage outputTokens::Int64 end -# ---------------------------------------------- 100 --------------------------------------------- # - -# ============================================================================ -# Message types -# ============================================================================ +# ------------------------------------------------------------------------------------------------ # +# Message types # +# ------------------------------------------------------------------------------------------------ # abstract type agentMessage end # Base type for all agent messages struct userMessage <: agentMessage # Message from the user @@ -135,9 +129,9 @@ function toolResultMessage(; role="tool", toolCallId="", toolName="", end -# ============================================================================ -# Message content types -# ============================================================================ +# ------------------------------------------------------------------------------------------------ # +# Message content types # +# ------------------------------------------------------------------------------------------------ # abstract type messageContent end # Base type for message content @@ -190,9 +184,9 @@ function imageContent(; data="", mimeType="") end -# ============================================================================ -# Tool types -# ============================================================================ +# ------------------------------------------------------------------------------------------------ # +# Tool types # +# ------------------------------------------------------------------------------------------------ # """ A tool available to the agent. @@ -220,9 +214,9 @@ struct agentTool{TParameters, TDetails} # A tool available to the agent end -# ============================================================================ -# Agent context -# ============================================================================ +# ------------------------------------------------------------------------------------------------ # +# Agent context # +# ------------------------------------------------------------------------------------------------ # """ Snapshot of the agent's conversation context. @@ -242,15 +236,18 @@ struct agentContext # Snapshot of the agent's conversa end -# ============================================================================ -# Agent state -# ============================================================================ +# ------------------------------------------------------------------------------------------------ # +# Agent state # +# ------------------------------------------------------------------------------------------------ # mutable struct agentState # Mutable runtime state of an agent systemPrompt::String # System prompt text model::llmModel # LLM model to use tools::Vector{agentTool} # Available tools - messages::Vector{agentMessage} # Conversation messages + + # messages history includes userMessage, assistantMessage, toolResultMessage + messages::Vector{agentMessage} + pendingToolCalls::Vector{String} # Tool call IDs waiting for results activeRun::Bool # is agent processing user message? errorMessage::Union{String, Nothing} # Last error message @@ -343,9 +340,148 @@ struct llmModel{Api} # LLM model configuration maxTokens::Int64 # Maximum output tokens per completion end -# ============================================================================ -# Agent struct -# ============================================================================ +# ------------------------------------------------------------------------------------------------ # +# Agent loop configuration & tool execution types # +# ------------------------------------------------------------------------------------------------ # + +""" +Configuration for the agent tool execution loop. + +# Arguments +- `tools::Vector{agentTool}`: Available tools +- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution +- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution +- `toolExecution::String`: Execution mode — "sequential" or "parallel" +""" +struct agentLoopConfig + tools::Vector{agentTool} + beforeToolCall::Union{Function, Nothing} + afterToolCall::Union{Function, Nothing} + toolExecution::String +end + +""" +Signal for aborting ongoing operations. + +# Arguments +- `aborted::Bool`: Whether the operation has been aborted +""" +struct abortSignal + aborted::Bool +end + +""" +Result returned by tool execution before the `afterToolCall` hook. + +# Arguments +- `content::Vector{messageContent}`: Tool output content +- `details::Dict{Any,Any}`: Tool-specific details +- `usage::Union{Usage, Nothing}`: Token usage if applicable +- `terminate::Bool`: Whether tool requests termination of the agent loop +""" +struct agentToolResult + content::Vector{messageContent} + details::Dict{Any,Any} + usage::Union{Usage, Nothing} + terminate::Bool +end + +""" +Function type for parallel execution override on a tool. + +# Arguments +- Context for parallel execution + +# Returns +- `agentToolCallBatch`: The result batch from parallel execution +""" +const toolparallelExecute = Function + +""" +Context passed to the `beforeToolCall` hook. + +# Arguments +- `message::assistantMessage`: The assistant message containing the tool call +- `toolCall::agentToolCall`: The tool call being prepared +- `args::Dict{String,Any}`: Validated tool arguments +- `context::agentContext`: Current conversation context +""" +struct assistantMsgCtx + message::assistantMessage + toolCall::agentToolCall + args::Dict{String,Any} + context::agentContext +end + +""" +Context passed to the `afterToolCall` hook. + +# Arguments +- `message::assistantMessage`: The assistant message containing the tool call +- `toolCall::agentToolCall`: The tool call that was executed +- `args::Dict{String,Any}`: Tool arguments +- `result::agentToolResult`: The raw tool result +- `isError::Bool`: Whether execution resulted in an error +- `context::agentContext`: Current conversation context +""" +struct afterCtx + message::assistantMessage + toolCall::agentToolCall + args::Dict{String,Any} + result::agentToolResult + isError::Bool + context::agentContext +end + +""" +Event emitted when a tool call execution starts. + +# Arguments +- `toolCallId::String`: ID of the tool call +- `toolName::String`: Name of the tool +- `arguments::Dict{String,Any}`: Tool arguments +""" +struct toolExecStartEvent + toolCallId::String + toolName::String + arguments::Dict{String,Any} +end + +""" +Event emitted with partial results during tool execution. + +# Arguments +- `toolCallId::String`: ID of the tool call +- `toolName::String`: Name of the tool +- `arguments::Dict{String,Any}`: Tool arguments +- `partialResult::Any`: The partial result data +""" +struct toolExecUpdateEvent + toolCallId::String + toolName::String + arguments::Dict{String,Any} + partialResult::Any +end + +""" +Event emitted when a tool call execution ends. + +# Arguments +- `toolCallId::String`: ID of the tool call +- `toolName::String`: Name of the tool +- `result::agentToolResult`: The final tool result +- `isError::Bool`: Whether execution resulted in an error +""" +struct toolExecEndEvent + toolCallId::String + toolName::String + result::agentToolResult + isError::Bool +end + +# ------------------------------------------------------------------------------------------------ # +# Agent struct # +# ------------------------------------------------------------------------------------------------ # abstract type agent end @@ -367,14 +503,14 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # and all followUp messages. outputChannel::Channel - _task::Union{Task, Nothing} # Background task running the agent loop + _agent_loop::Union{Task, Nothing} # agent loop running in the background # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, # reorder, ...) for a single LLM call in _process_message()'s loop. # returns new Vector{agentMessage} - preprocessContext ::Union{Function, Nothing} + prepareContext ::Union{Function, Nothing} - # Convert preprocessContext()'s new Vector{agentMessage} to LLM message format + # Convert prepareContext()'s new Vector{agentMessage} to LLM message format formatMsgForLLM::Function # Actually invoke the LLM to get a completion response. The LLM response comes back as an @@ -391,8 +527,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Callback invoked after executing a tool call to sanitize tools output so the output is ready # to be converted into toolResults message afterToolCall::Union{Function, Nothing} - prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn - prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context + # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn + # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false @@ -412,7 +548,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) - `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) - `llmCall::Function`: Function to invoke the LLM (required) -- `preprocessContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) +- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) - `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) - `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) - `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) @@ -436,13 +572,13 @@ function yiemAgent( model=nothing, tools::Vector{agentTool}=agentTool[], messages::Vector{agentMessage}=agentMessage[], - preprocessContext::Union{Function, Nothing}=nothing, + prepareContext::Union{Function, Nothing}=nothing, formatMsgForLLM::Function=defaultformatMsgForLLM, llmCall::Function, beforeToolCall::Union{Function, Nothing}=nothing, afterToolCall::Union{Function, Nothing}=nothing, - prepareNextTurn::Union{Function, Nothing}=nothing, - prepareNextTurnWithContext::Union{Function, Nothing}=nothing, + # prepareNextTurn::Union{Function, Nothing}=nothing, + # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, @@ -460,13 +596,13 @@ function yiemAgent( followUp, outputChannel, nothing, # placeholder — replaced below - preprocessContext, + prepareContext, formatMsgForLLM, llmCall, beforeToolCall, afterToolCall, - prepareNextTurn, - prepareNextTurnWithContext, + # prepareNextTurn, + # prepareNextTurnWithContext, sessionId, maxRetryDelayMs, parallelToolExecute, @@ -474,7 +610,7 @@ function yiemAgent( ) # Spawn the background loop and attach it - agent._task = @spawn _agent_loop(agent) + agent._agent_loop = @spawn _agent_loop(agent) return agent end diff --git a/src/util.jl b/src/util.jl deleted file mode 100644 index 9460d97..0000000 --- a/src/util.jl +++ /dev/null @@ -1,505 +0,0 @@ -module util - -export clearhistory, addNewMessage, chatHistoryToText, eventdict, noises, createTimeline, - availableWineToText, createEventsLog, createChatLog, checkAgentResponse_JSON, - checkAgentResponse_text - -using UUIDs, Dates, DataStructures, HTTP, JSON -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 - - -""" -Add a new message to the agent's conversation history. - -Automatically summarizes the oldest messages if the history exceeds `maximumMsg`. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) -- `name::String`: Message sender role (e.g. "system", "user", "assistant") -- `userinput::T2`: Message dictionary to append (must contain "name" and "text" keys) - -# Keyword Arguments -- `maximumMsg::Integer=30`: Maximum number of messages before summarization kicks in - -# Returns -- `nothing` - -# Notes -- When history length exceeds `maximumMsg`, the oldest messages are summarized automatically. - -# Examples -```jldoctest -julia> YiemAgent.addNewMessage(agent, "user", Dict("name" => "user", "text" => "hello")) -``` -""" -function addNewMessage(a::T1, name::String, userinput::T2; - maximumMsg::Integer=30) where {T1<:agent, T2<:AbstractDict} - # if name ∉ ["system", "user", "assistant"] # guard against typo - # error("name is not in agent.availableRole $(@__LINE__)") - # end - - #TODO summarize the oldest 10 message - if length(a.chathistory) > maximumMsg - summarize(a.chathistory) - else - # userinput["timestamp"] = Dates.now() - push!(a.chathistory, userinput) - end -end - - -""" Converts a vector of dictionaries to a formatted string. -This function takes in a vector of dictionaries and outputs a single string where each dictionary's keys are prefixed by their values. - -# Arguments - - `vecd::Vector` - A vector of dictionaries containing chat messages - - `withkey::Bool` - Whether to include the name as a prefix in the output text. Default is true - - `range::Union{Nothing,UnitRange,Int}` - Optional range of messages to include. If nothing, includes all messages - -# Returns - A formatted string where each line contains either: - - If withkey=true: "name> message\n" - - If withkey=false: "message\n" - -# Example - -julia> using Revise -julia> using GeneralUtils -julia> vecd = [Dict("name" => "John", "text" => "Hello"), Dict("name" => "Jane", "text" => "Goodbye")] -julia> GeneralUtils.vectorOfDictToText(vecd, withkey=true) -"John> Hello\nJane> Goodbye\n" -``` -""" -function chatHistoryToText(vecd::Vector; withkey=true, range=nothing)::String - # Initialize an empty string to hold the final text - text = "" - - # Get the elements within the specified range, or all elements if no range provided - elements = isnothing(range) ? vecd : vecd[range] - - # Determine whether to include the key in the output text or not - if withkey - # Loop through each dictionary in the input vector - for d in elements - # Extract the 'name' and 'text' keys from the dictionary - name = titlecase(d[:name]) - _text = d[:text] - - # Append the formatted string to the text variable - text *= "$name> $_text \n" - end - else - # Loop through each dictionary in the input vector - for d in elements - # Iterate over all key-value pairs in the dictionary - for (k, v) in d - # Append the formatted string to the text variable - text *= "$v \n" - end - end - end - - # Return the final text - return text -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 - - - -""" -Create a dictionary representing an event with optional details. - -# Keyword Arguments -- `event_description::Union{String, Nothing}`: A description of the event -- `timestamp::Union{DateTime, Nothing}`: The time when the event occurred -- `subject::Union{String, Nothing}`: The subject or entity associated with the event -- `thought::Union{AbstractDict, Nothing}`: Any associated thoughts or metadata -- `action_name::Union{String, Nothing}`: The name of the action performed (e.g., "CHAT", "CHECKINVENTORY") -- `action_input::Union{String, Nothing}`: Input or parameters for the action -- `location::Union{String, Nothing}`: Where the event took place -- `equipment_used::Union{String, Nothing}`: Equipment involved in the event -- `material_used::Union{String, Nothing}`: Materials used during the event -- `observation::Union{String, Nothing}`: Observation of the event -- `note::Union{String, Nothing}`: Additional notes or comments - -# Returns -- A `Dict{String, Any}` with event details as string-keyed key-value pairs - -# Examples -```jldoctest -julia> YiemAgent.eventdict(action_name="CHAT", action_input="hello") -Dict{String, Any} with 11 entries: ... -``` -""" -function eventdict(; - event_description::Union{String, Nothing}=nothing, - timestamp::Union{DateTime, Nothing}=nothing, - subject::Union{String, Nothing}=nothing, - thought::Union{AbstractDict, Nothing}=nothing, - action_name::Union{String, Nothing}=nothing, # "CHAT", "CHECKINVENTORY", "PRESENT_WINE_GUIDELINE", etc - action_input::Union{String, Nothing}=nothing, - location::Union{String, Nothing}=nothing, - equipment_used::Union{String, Nothing}=nothing, - material_used::Union{String, Nothing}=nothing, - observation::Union{String, Nothing}=nothing, - note::Union{String, Nothing}=nothing, - ) - - d = Dict{String, Any}( - "event_description"=> event_description, - "timestamp"=> timestamp, - "subject"=> subject, - "thought"=> thought, - "action_name"=> action_name, - "action_input"=> action_input, - "location"=> location, - "equipment_used"=> equipment_used, - "material_used"=> material_used, - "observation"=> observation, - "note"=> note, - ) - - return d -end - - -""" -Create a formatted timeline string from a sequence of events. - -# Arguments -- `events::T1`: Vector of event dictionaries. Each must have `action_name` and `action_input` keys, - and optionally `subject` and `observation` keys. - -# Keyword Arguments -- `eventindex::Union{UnitRange, Nothing}=nothing`: Optional range of event indices to include. - If `nothing`, all events are included. - -# Returns -- `timeline::String`: A formatted string where each event appears on its own line in the format: - `"Event_{index} {subject}> action_name: {action_name}, action_input: {action_input}"` - If `observation` is present, it is appended. - -# Examples -```jldoctest -julia> events = [ - Dict("subject" => "User", "action_input" => "Hello", "action_name" => "CHAT", "observation" => nothing), - Dict("subject" => "Assistant", "action_input" => "Hi there!", "action_name" => "CHAT", "observation" => "with a smile") - ]; -julia> YiemAgent.createTimeline(events) -"Event_1 User> action_name: CHAT, action_input: Hello\\nEvent_2 Assistant> action_name: CHAT, action_input: Hi there!\\n" -``` -""" -function createTimeline(events::T1; eventindex::Union{UnitRange, Nothing}=nothing - ) where {T1<:AbstractVector} - # Initialize empty timeline string - timeline = "" - - # Determine which indices to use - either provided range or full length - ind = - if eventindex !== nothing - [eventindex...] - else - 1:length(events) - end - - # Iterate through events and format each one - for i in ind - event = events[i] - # If no outcome exists, format without outcome -# if event["action_name"] == "CHAT_BOX" - # timeline *= "Event_$i $(event["subject"])> action_name: $(event["action_name"]), action_input: $(event["action_input"])\n" - # elseif event["action_name"] == "CHECKINVENTORY" && event["observation"] === nothing - # timeline *= "Event_$i $(event["subject"])> action_name: $(event["action_name"]), action_input: $(event["action_input"]), observation: Not done yet.\n" - if event["action_name"] == "SEARCH_WINE_DATABASE" - timeline *= "Event_$i $(event["subject"])> action_name: $(event["action_name"]), action_input: $(event["action_input"]), observation: $(event["observation"])\\n" - else - timeline *= "Event_$i $(event["subject"])> action_name: $(event["action_name"]), action_input: $(event["action_input"])\\n" - end - end - - # Return formatted timeline string - return timeline -end - -""" -Create a formatted event log from a sequence of events. - -# Arguments -- `events::T1`: Vector of event dictionaries. Each must have `subject`, `action_name`, `action_input`, - and optionally `observation` keys. - -# Keyword Arguments -- `index::Union{UnitRange, Nothing}=nothing`: Optional range of event indices to include. - If `nothing`, all events are included. - -# Returns -- A `Vector{Dict{String, String}}` where each dictionary has `"name"` (from event subject) and - `"text"` (formatted action description) keys. - -# Examples -```jldoctest -julia> events = [Dict("subject" => "User", "action_name" => "CHAT", "action_input" => "hello", "observation" => nothing)]; -julia> log = YiemAgent.createEventsLog(events); -julia> log[1]["name"] -"User" -``` -""" -function createEventsLog(events::T1; index::Union{UnitRange, Nothing}=nothing - ) where {T1<:AbstractVector} - # Initialize empty log array - log = Dict{String, String}[] - - # Determine which indices to use - either provided range or full length - ind = - if index !== nothing - [index...] - else - 1:length(events) - end - - # Iterate through events and format each one - for i in ind - event = events[i] - # If no outcome exists, format without outcome - if event["observation"] === nothing - subject = event["subject"] - action_name = event["action_name"] - action_input = event["action_input"] - str = "action_name: $action_name, action_input: $action_input" - d = Dict{String, String}("name"=>subject, "text"=>str) - push!(log, d) - else - subject = event["subject"] - action_name = event["action_name"] - action_input = event["action_input"] - observation = event["observation"] - str = "action_name: $action_name, action_input: $action_input, observation: $observation" - d = Dict{String, String}("name"=>subject, "text"=>str) - push!(log, d) - end - end - - return log -end - - -""" -Create a formatted chat log from a sequence of chat entries. - -# Arguments -- `chatdict::T1`: Vector of chat entry dictionaries. Each must have `"name"` and `"text"` keys. - -# Keyword Arguments -- `index::Union{UnitRange, Nothing}=nothing`: Optional range of entry indices to include. - If `nothing`, all entries are included. - -# Returns -- A `Vector{Dict{String, String}}` where each dictionary has `"name"` and `"text"` keys - copied from the corresponding input entry. - -# Examples -```jldoctest -julia> chats = [Dict("name" => "user", "text" => "hello"), Dict("name" => "assistant", "text" => "hi")]; -julia> YiemAgent.createChatLog(chats)[1]["name"] -"user" -``` -""" -function createChatLog(chatdict::T1; index::Union{UnitRange, Nothing}=nothing - ) where {T1<:AbstractVector} - # Initialize empty log array - log = Dict{String, String}[] - - # Determine which indices to use - either provided range or full length - ind = - if index !== nothing - [index...] - else - 1:length(chatdict) - end - - # Iterate through events and format each one - for i in ind - event = chatdict[i] - subject = event["name"] - text = event["text"] - d = Dict{String, String}("name"=>subject, "text"=>text) - push!(log, d) - end - - return log -end - - -""" -Check if an agent's text response contains all required header keywords. - -Validates that the response includes all required keywords without duplications. - -# Arguments -- `response::String`: The agent's text response to validate -- `requiredHeader::T`: Array of required keyword strings (subtype of `Array{String}`) - -# Returns -- `Tuple{Bool, Union{String, Nothing}}`: A two-element tuple where: - - First element: `true` if all required keywords are present and not duplicated, `false` otherwise - - Second element: An error description string if validation failed, or `nothing` if passed - -# Notes -- Uses `GeneralUtils.detectKeywordVariation` for flexible keyword matching. - -# Examples -```jldoctest -julia> ispass, err = YiemAgent.checkAgentResponse_text("hello world", ["hello"]) -(true, nothing) -``` -""" -function checkAgentResponse_text(response::String, requiredHeader::T - )::Tuple where {T<:Array{String}} - detected_kw = GeneralUtils.detectKeywordVariation(requiredHeader, response) - missingkeys = [k for (k, v) in detected_kw if v === nothing] - ispass = false - errormsg = nothing - if !isempty(missingkeys) - errormsg = "$missingkeys are missing from your previous response" - ispass = false - elseif sum([length(i) for i in values(detected_kw)]) > length(requiredHeader) - errormsg = "Your previous attempt has duplicated points according to the required response format" - ispass = false - else - ispass = true - end - return (ispass, errormsg) -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module util \ No newline at end of file diff --git a/src/utils.jl b/src/utils.jl new file mode 100644 index 0000000..ab0b7e0 --- /dev/null +++ b/src/utils.jl @@ -0,0 +1,179 @@ +module utils + +export clearhistory, availableWineToText, prepareContext + +using UUIDs, Dates, DataStructures, HTTP, JSON +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) -> Vector{agentMessage} + +Returns a deep copy of the messages from the given `agentState`, ready +to be sent to the LLM. Override this function to inject additional +context — such as retrieved documents, current time, user preferences, +or any other relevant information — into the message list before +formatting and calling the LLM. + +By default, returns an exact copy of `state.messages` without +modification. + +# Arguments +- `state::agentState`: The current agent state containing conversation history + +# Returns +- `Vector{agentMessage}`: A deep copy of the messages to be sent to the LLM + +# Examples +```julia +# Default: returns a deep copy of messages +prepareContext(state) == deepcopy(state.messages) + +# Override to inject system context: +# function Base.prepareContext(state::agentState) +# msgs = deepcopy(state.messages) +# pushfirst!(msgs, textMessage("system", "You are a helpful assistant.")) +# return msgs +# end +``` +""" +function prepareContext(state::agentState)::Vector{agentMessage} + messages = deepcopy(state.messages) # messages that will be send to LLM + + #TODO adjust/modify and inject additional context into messages + + return messages +end + + +function formatMsgForLLM() + +end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +end # module util \ No newline at end of file diff --git a/src/llmfunction.jl b/src_OLD/llmfunction.jl similarity index 100% rename from src/llmfunction.jl rename to src_OLD/llmfunction.jl -- 2.52.0 From 8232946e91171b97485be0b6be46eccc78ff4b7a Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 18:29:31 +0700 Subject: [PATCH 028/112] update --- src/utils.jl | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index ab0b7e0..b82735a 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -113,8 +113,28 @@ function prepareContext(state::agentState)::Vector{agentMessage} end -function formatMsgForLLM() - +""" convert preparedcontext into openai message format ready to be used by LLM +""" +function formatMsgForLLM(messages::Vector{agentMessage})::Vector{Dict{String, Any}} + """ openai message format + message = Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => + " + Do you have Brunello di Montalcino from Tenuta CastelGiocon? + "), + Dict( + "type" => "image_url", + "image_url" => Dict("url" => data1_uri) + ) + ] + ) + """ + + # convert various messages to openai message format + + return openai_message end -- 2.52.0 From e592441387c6225c721066c605049d93f0fb6740 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 18:40:09 +0700 Subject: [PATCH 029/112] update --- src/utils.jl | 59 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index b82735a..f274980 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -116,19 +116,40 @@ end """ convert preparedcontext into openai message format ready to be used by LLM """ function formatMsgForLLM(messages::Vector{agentMessage})::Vector{Dict{String, Any}} - """ openai message format - message = Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => - " - Do you have Brunello di Montalcino from Tenuta CastelGiocon? - "), + """ openai message format example + msg = Dict( + "model" => "gemma-4-E4B-it-UD-Q4_K_XL", + "messages" => [ Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] + "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" => data1_uri) + ) + ] + ), + Dict( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => "let me check."), + ] + ), + Dict( + "role" => "toolResult", + "content" => [ + Dict("type" => "text", "text" => "name: Chateau Montelena ..."), + ] + ), + ], + "temperature" => 0.7 ) """ @@ -141,7 +162,19 @@ end - +message = 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" => data1_uri) + ) + ] + ) -- 2.52.0 From dde9b019ddc2a436834d450655a74418cabcb9fb Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 18:44:28 +0700 Subject: [PATCH 030/112] update --- src/utils.jl | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index f274980..de364ed 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -162,22 +162,6 @@ end -message = 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" => data1_uri) - ) - ] - ) - - - -- 2.52.0 From ebd79aa6866ad06d47f0748c838da38b8fbe307e Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 18:59:39 +0700 Subject: [PATCH 031/112] update --- src/utils.jl | 87 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index de364ed..911eee2 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -116,46 +116,59 @@ end """ convert preparedcontext into openai message format ready to be used by LLM """ function formatMsgForLLM(messages::Vector{agentMessage})::Vector{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" => data1_uri) - ) - ] - ), - Dict( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => "let me check."), - ] - ), - Dict( - "role" => "toolResult", - "content" => [ - Dict("type" => "text", "text" => "name: Chateau Montelena ..."), - ] - ), - ], - "temperature" => 0.7 + + openai_messages = Vector{Dict{String, Any}}() + + for msg in messages + if msg isa userMessage + push!(openai_messages, _toOpenAIUserMsg(msg)) + elseif msg isa assistantMessage + push!(openai_messages, _toOpenAIAssistantMsg(msg)) + elseif msg isa toolResultMessage + push!(openai_messages, _toOpenAIToolResultMsg(msg)) + end + end + + return openai_messages +end + +function _toOpenAIUserMsg(msg::userMessage)::Dict{String, Any} + return Dict{String, Any}( + "role" => "user", + "content" => _toOpenAIBlocks(msg.content), ) - """ +end - # convert various messages to openai message format +function _toOpenAIAssistantMsg(msg::assistantMessage)::Dict{String, Any} + return Dict{String, Any}( + "role" => "assistant", + "content" => _toOpenAIBlocks(msg.content), + ) +end - return openai_message +function _toOpenAIToolResultMsg(msg::toolResultMessage)::Dict{String, Any} + return Dict{String, Any}( + "role" => "tool", + "tool_call_id" => msg.toolCallId, + "name" => msg.toolName, + "content" => _toOpenAIBlocks(msg.content), + ) +end + +function _toOpenAIBlocks(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 + data_uri = "data:$(c.mimeType);base64,$(c.data)" + push!(blocks, Dict( + "type" => "image_url", + "image_url" => Dict("url" => data_uri), + )) + end + end + return blocks end -- 2.52.0 From 51fca0aa8c930c7ed0f31a21e97ba2fbfde44cb7 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 19:30:59 +0700 Subject: [PATCH 032/112] update --- src/agentCore.jl | 2 +- src/utils.jl | 89 +++++++++++++++++++++--------------------------- 2 files changed, 39 insertions(+), 52 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index dd27893..18defc9 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -179,7 +179,7 @@ function _process_message(agent::yiemAgent)::assistantMessage # call agent.prepareContext() preparedContext = agent.prepareContext(agent._state) - #WORKING Call agent.formatMsgForLLM(agent._state) to format for LLM + # Call agent.formatMsgForLLM(agent._state) to format for LLM formatted_messages = agent.formatMsgForLLM(preparedContext) # Call llmCall() (blocking — the task waits here) diff --git a/src/utils.jl b/src/utils.jl index 911eee2..38729f4 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -115,60 +115,47 @@ end """ convert preparedcontext into openai message format ready to be used by LLM """ -function formatMsgForLLM(messages::Vector{agentMessage})::Vector{Dict{String, Any}} - - openai_messages = Vector{Dict{String, Any}}() - - for msg in messages - if msg isa userMessage - push!(openai_messages, _toOpenAIUserMsg(msg)) - elseif msg isa assistantMessage - push!(openai_messages, _toOpenAIAssistantMsg(msg)) - elseif msg isa toolResultMessage - push!(openai_messages, _toOpenAIToolResultMsg(msg)) - end - end - - return openai_messages -end - -function _toOpenAIUserMsg(msg::userMessage)::Dict{String, Any} - return Dict{String, Any}( - "role" => "user", - "content" => _toOpenAIBlocks(msg.content), +function formatMsgForLLM(preparedContext::Vector{agentMessage})::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" => data1_uri) + ) + ] + ), + Dict( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => "let me check."), + ] + ), + Dict( + "role" => "toolResult", + "content" => [ + Dict("type" => "text", "text" => "name: Chateau Montelena ..."), + ] + ), + ], + "temperature" => 0.7 ) -end + """ -function _toOpenAIAssistantMsg(msg::assistantMessage)::Dict{String, Any} - return Dict{String, Any}( - "role" => "assistant", - "content" => _toOpenAIBlocks(msg.content), - ) -end + # convert various messages to openai message format -function _toOpenAIToolResultMsg(msg::toolResultMessage)::Dict{String, Any} - return Dict{String, Any}( - "role" => "tool", - "tool_call_id" => msg.toolCallId, - "name" => msg.toolName, - "content" => _toOpenAIBlocks(msg.content), - ) -end - -function _toOpenAIBlocks(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 - data_uri = "data:$(c.mimeType);base64,$(c.data)" - push!(blocks, Dict( - "type" => "image_url", - "image_url" => Dict("url" => data_uri), - )) - end - end - return blocks + return openai_message end -- 2.52.0 From d0bacbb53899e25f118bab796a9598af21c28a37 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 6 Aug 2026 19:39:42 +0700 Subject: [PATCH 033/112] update --- src/utils.jl | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index 38729f4..2efd78b 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -153,9 +153,79 @@ function formatMsgForLLM(preparedContext::Vector{agentMessage})::Dict{String, An ) """ - # convert various messages to openai message format + messages = Dict{String, Any}[] - return openai_message + for msg in preparedContext + if msg isa userMessage + push!(messages, _userMessageToOpenAI(msg)) + elseif msg isa assistantMessage + push!(messages, _assistantMessageToOpenAI(msg)) + elseif msg isa toolResultMessage + push!(messages, _toolResultMessageToOpenAI(msg)) + end + end + + return Dict("messages" => messages) +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 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 -- 2.52.0 From 0a6af36b345b2ac0ac730e55a549ce23fefa652f Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 08:46:48 +0700 Subject: [PATCH 034/112] update --- src/agentCore.jl | 4 +- src/type.jl | 4 +- src/utils.jl | 18 ++- test/chatting_with_agent.jl | 296 ---------------------------------- test/prompttest.jl | 159 ------------------ test/prompttest_1.jl | 87 ---------- test/prompttest_2.jl | 119 -------------- test/{test1.jl => runtest.jl} | 0 test/runtests.jl | 223 ------------------------- 9 files changed, 18 insertions(+), 892 deletions(-) delete mode 100644 test/chatting_with_agent.jl delete mode 100644 test/prompttest.jl delete mode 100644 test/prompttest_1.jl delete mode 100644 test/prompttest_2.jl rename test/{test1.jl => runtest.jl} (100%) delete mode 100644 test/runtests.jl diff --git a/src/agentCore.jl b/src/agentCore.jl index 18defc9..311d00e 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -184,8 +184,10 @@ function _process_message(agent::yiemAgent)::assistantMessage # Call llmCall() (blocking — the task waits here) response = agent.llmCall(formatted_messages) + + error(5555555) - # Check if LLM used tool calls (inspect content for tool_call blocks) + #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) has_tool_calls = false tool_call_list = agentToolCall[] diff --git a/src/type.jl b/src/type.jl index c7cb570..264cdd7 100644 --- a/src/type.jl +++ b/src/type.jl @@ -245,7 +245,7 @@ mutable struct agentState # Mutable runtime state of an agen model::llmModel # LLM model to use tools::Vector{agentTool} # Available tools - # messages history includes userMessage, assistantMessage, toolResultMessage + # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt messages::Vector{agentMessage} pendingToolCalls::Vector{String} # Tool call IDs waiting for results @@ -568,7 +568,7 @@ yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) ``` """ function yiemAgent( - ; systemPrompt::String="", + ; systemPrompt::String="You are helpful assistant.", model=nothing, tools::Vector{agentTool}=agentTool[], messages::Vector{agentMessage}=agentMessage[], diff --git a/src/utils.jl b/src/utils.jl index 2efd78b..0612a78 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -103,13 +103,21 @@ prepareContext(state) == deepcopy(state.messages) # return msgs # end ``` -""" -function prepareContext(state::agentState)::Vector{agentMessage} - messages = deepcopy(state.messages) # messages that will be send to LLM +""" #WORKING +function prepareContext(state::agentState)::agentContext - #TODO adjust/modify and inject additional context into messages + #TODO filter tools from state.tools based on user intend in user message and tool description + filteredTools = state.tools - return messages + #TODO add tools to current system prompt + 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) + + return agentCtx end diff --git a/test/chatting_with_agent.jl b/test/chatting_with_agent.jl deleted file mode 100644 index 5e66a0f..0000000 --- a/test/chatting_with_agent.jl +++ /dev/null @@ -1,296 +0,0 @@ -using Revise -using JSON, JSON3, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames -using YiemAgent, GeneralUtils -using Base.Threads - -# ---------------------------------------------- 100 --------------------------------------------- # - - - -# load config -config = JSON.parsefile("/appfolder/app/dev/YiemAgent/test/config.json") -# config = copy(JSON.parsefile("../mountvolume/config.json")) - - -function executeSQL(sql::T) where {T<:AbstractString} - host = config[:externalservice][:wineDB][:host] - port = config[:externalservice][:wineDB][:port] - dbname = config[:externalservice][:wineDB][:dbname] - user = config[:externalservice][:wineDB][:user] - password = config[:externalservice][:wineDB][:password] - DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result -end - -function executeSQLVectorDB(sql) - host = config[:externalservice][:SQLVectorDB][:host] - port = config[:externalservice][:SQLVectorDB][:port] - dbname = config[:externalservice][:SQLVectorDB][:dbname] - user = config[:externalservice][:SQLVectorDB][:user] - password = config[:externalservice][:SQLVectorDB][:password] - DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result -end - -function text2textInstructLLM(prompt::String; maxattempt::Integer=3, modelsize::String="medium", - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.1, - ) - ) - msgMeta = GeneralUtils.generate_msgMeta( - config[:externalservice][:loadbalancer][:mqtttopic]; - msgPurpose="inference", - senderName="yiemagent", - senderId=sessionId, - receiverName="text2textinstruct_$modelsize", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :text => prompt, - :kwargs => llmkwargs - ) - ) - - response = nothing - for attempts in 1:maxattempt - _response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=180, maxattempt=maxattempt) - payload = _response[:response] - if _response[:success] && payload[:text] !== nothing - response = _response[:response][:text] - break - else - println("\n attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(outgoingMsg) - println(" attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - sleep(3) - end - end - - return response -end - -# get text embedding from a LLM service -function getEmbedding(text::T) where {T<:AbstractString} - msgMeta = GeneralUtils.generate_msgMeta( - config[:externalservice][:loadbalancer][:mqtttopic]; - msgPurpose="embedding", - senderName="yiemagent", - senderId=sessionId, - receiverName="textembedding", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :text => [text] # must be a vector of string - ) - ) - - response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120, maxattempt=3) - embedding = response[:response][:embeddings] - return embedding -end - -function findSimilarTextFromVectorDB(text::T1, tablename::T2, embeddingColumnName::T3, - vectorDB::Function; limit::Integer=1 - )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} - # get embedding from LLM service - embedding = getEmbedding(text)[1] - # check whether there is close enough vector already store in vectorDB. if no, add, else skip - sql = """ - SELECT *, $embeddingColumnName <-> '$embedding' as distance - FROM $tablename - ORDER BY distance LIMIT $limit; - """ - response = vectorDB(sql) - df = DataFrame(response) - return df -end - -function similarSQLVectorDB(query; maxdistance::Integer=100) - tablename = "sqlllm_decision_repository" - # get embedding of the query - df = findSimilarTextFromVectorDB(query, tablename, - "function_input_embedding", executeSQLVectorDB) - # println(df[1, [:id, :function_output]]) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - # distance = 100 # CHANGE this is for testing only - if row != 0 && distance < maxdistance - # if there is usable SQL, return it. - output_b64 = df[1, :function_output_base64] # pick the closest match - output_str = String(base64decode(output_b64)) - rowid = df[1, :id] - println("\n~~~ found similar sql. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (dict=output_str, distance=distance) - else - println("\n~~~ similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (dict=nothing, distance=nothing) - end -end - -function insertSQLVectorDB(query::T1, SQL::T2; maxdistance::Integer=3) where {T1<:AbstractString, T2<:AbstractString} - tablename = "sqlllm_decision_repository" - # get embedding of the query - # query = state[:thoughtHistory][:question] - df = findSimilarTextFromVectorDB(query, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - query_embedding = getEmbedding(query)[1] - query = replace(query, "'" => "") - sql_base64 = base64encode(SQL) - sql_ = replace(SQL, "'" => "") - - sql = """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); - """ - # println("\n~~~ added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(sql) - _ = executeSQLVectorDB(sql) - end -end - - -function similarSommelierDecision(recentevents::T1; maxdistance::Integer=3 - )::Union{AbstractDict, Nothing} where {T1<:AbstractString} - tablename = "sommelier_decision_repository" - # find similar - println("\n~~~ search vectorDB for this: $recentevents ", @__FILE__, " ", @__LINE__) - df = findSimilarTextFromVectorDB(recentevents, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable decision, return it. - rowid = df[1, :id] - println("\n~~~ found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) - output_b64 = df[1, :function_output_base64] # pick the closest match - _output_str = String(base64decode(output_b64)) - output = copy(JSON.parsefile(_output_str)) - return output - else - println("\n~~~ similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) - return nothing - end -end - - -function insertSommelierDecision(recentevents::T1, decision::T2; maxdistance::Integer=5 - ) where {T1<:AbstractString, T2<:AbstractDict} - tablename = "sommelier_decision_repository" - # find similar - df = findSimilarTextFromVectorDB(recentevents, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - recentevents_embedding = getEmbedding(recentevents)[1] - recentevents = replace(recentevents, "'" => "") - decision_json = JSON.json(decision) - decision_base64 = base64encode(decision_json) - decision = replace(decision_json, "'" => "") - - sql = """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); - """ - println("\n~~~ added new decision to vectorDB ", @__FILE__, " ", @__LINE__) - println(sql) - _ = executeSQLVectorDB(sql) - else - println("~~~ similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) - end -end - - -sessionId = "12345" - -externalFunction = ( - getEmbedding=getEmbedding, - text2textInstructLLM=text2textInstructLLM, - executeSQL=executeSQL, - similarSQLVectorDB=similarSQLVectorDB, - insertSQLVectorDB=insertSQLVectorDB, - similarSommelierDecision=similarSommelierDecision, - insertSommelierDecision=insertSommelierDecision, - ) - - - -a = YiemAgent.sommelier( - externalFunction; - name="Ton", - id=sessionId, # agent instance id - retailername="Yiem", -) - -while true - print("\nyour respond: ") - user_answer = readline() - response = YiemAgent.conversation(agent; - userinput=Dict(:text=> user_answer), - maximumMsg=50) - println("\n$response") -end - - -# response = YiemAgent.conversation(a, Dict(:text=> "I want to get a French red wine under 100.")) - - -""" -hello I want to get a bottle of red wine for my boss. I have a budget around 50 dollars. Show me some options. - -I have no idea about his wine taste but he likes spicy food. - - -""" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/prompttest.jl b/test/prompttest.jl deleted file mode 100644 index b75dd4c..0000000 --- a/test/prompttest.jl +++ /dev/null @@ -1,159 +0,0 @@ -using Revise -using YiemAgent, GeneralUtils, JSON3, DataStructures - -thoughtDict = OrderedDict( - :Question=> "Hello, I would like a get a bottle of wine", - :Thought_1=> "The customer wants to buy a bottle of wine, but we need more information about their preferences.", - :Action_1=> Dict( - :name=> "chatbox", - :input=> "What occasion are you buying the wine for?", - ), - :Observation_1=> "We are having a wedding pary this weekend.", - - :Thought_2=> "A wedding party is a great occasion to have a good bottle of wine.", - :Action_2=> Dict( - :name=> "chatbox", - :input=> "What type of food will you be serving with the wine?", - ), - :Observation_2=> "I think it is Thai dishes", - - :Thought_3=> "Now that I know the occasion and food, I need to ask about the budget.", - :Action_3=> Dict( - :name=> "chatbox", - :input=> "What is your budget for this wine?", - ), - :Observation_3=> "50 bucks", - - :Thought_4=> "With a budget of \$50, we have a wide range of options. Now that I know it's a wedding party and Thai dishes, I need to ask about the type of wine they prefer.", - :Action_4=> Dict( - :name=> "chatbox", - :input=> "What type of wine are you looking for? (Red, White, Sparkling, Rose, Dessert, Fortified)", - ), - :Observation_4=> "Sparkling please.", - - :Thought_5=> "Now that I know the occasion, food, budget and preferred type of wine, it's time to check our inventory for the best matching wine.", - :Action_5=> Dict( - :name=> "winestock", - :input=> "wine with budget \$50, Thai dishes, sparkling, wedding party", - ), - :Observation_5=> "I found the following wine in stock {1 : Zena Crown Vista, 2 : Schrader Cabernet Sauvignon}", - - :Thought_6=> "Now that I have all the information, it's time to recommend a wine that fits their preferences.", - :Action_6=> Dict( - :name=> "recommendation", - :input=> "I recommend Zena Crown Vista for its sparkling and affordable price.", - ), - :Observation_6=> "I don't like it. Do you have another option?", - ) - -_thoughtJsonStr = JSON.json(thoughtDict) -thoughtJsonStr = _thoughtJsonStr[1:end-1] # remove } at the end -# @show thoughtJsonStr - -_, latestThoughtIndice = GeneralUtils.findHighestIndexKey(thoughtDict, "Thought") -nextThoughtIndice = latestThoughtIndice + 1 - -_prompt = -""" -You are a helpful sommelier working for a wine store. -Your goal is to reccommend the best wine from your inventory that match the user preferences. - -You must follow the following criteria: -1) Get to know what occasion the user is buying wine for -2) Get to know what food the user will have with wine -3) Get to know how much the user willing to spend -4) Get to know type of wine the user is looking for e.g. Red, White, Sparkling, Rose, Dessert, Fortified -5) Get to know what characteristics of wine the user is looking for - e.g. tannin, sweetness, intensity, acidity -6) Check your inventory for the best wine that match the user preference -7) Recommend wine to the user - -You should only respond with interleaving Thought, Action, Observation steps. -Thought can reason about the current situation, and Action can be three types: -1) winestock[query], which you can use to find wine in your inventory. The more input data the better. -2) chatbox[text], which you can use to interact with the user. -3) recommendation[answer], which returns your wine reccommendation to the user. - -You should only respond in JSON format as describe below: -{ - "Thought": "your reasoning", - "Action": {"name": "action to take", "input": "Action input"}, - "Observation": "result of the action" -} - -Here are some examples: -{ -"Question": "I would like to buy a sedan with 8 seats.", -"Thought_1": "Our showroom carries various vehicle model. But I'm not sure whether we have a models that fits the user demand, I need to check our inventory.", -"Action_1": {"name": "inventory", "input": "sedan with 8 seats."}, -"Observation_1": "Several model has 8 seats. Available color are black, red green" -} -{ - "Thought_2": "I have to ask the user what color he likes.", - "Action_2": {"name": "chatbox", "input": "Which color do you like?"} - "Observation_2": "I'll take black." -} -{ - "Thought_3": "There is only one model that fits the user preference. It's Yiem model A", - "Action_3": {"name": "recommendation", "input": "I recommend a Yiem model A"} -} - -Let's begin! - -$(JSON.json(thoughtDict)) -{Thought_$nextThoughtIndice -""" - -prompt = YiemAgent.formatLLMtext_llama3instruct("system", _prompt) -@show prompt -msgMeta = Dict(:requestResponse => nothing, - :msgPurpose => nothing, - :receiverId => nothing, - :getPost => nothing, - :msgId => "4c7111e0-c30e-44c3-8f85-1c8b3f03a8be", - :acknowledgestatus => nothing, - :replyToMsgId => nothing, - :msgFormatVersion => nothing, - :mqttServerInfo => Dict(:port => 1883, :broker => "mqtt.yiem.cc"), - :sendTopic => "/loadbalancer/requestingservice", - :receiverName => "text2textinstruct", - :replyTopic => nothing, - :senderName => "decisionMaker", - :senderSelfnote => nothing, - :senderId => "testingSessionID", - :timeStamp => "2024-05-04T08:06:23.561" - ) - -outgoingMsg = Dict( - :msgMeta=> msgMeta, - :payload=> Dict( - :text=> prompt, - ) -) - - - -_response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg) -thoughtJsonStr = _response[:response][:text] - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/prompttest_1.jl b/test/prompttest_1.jl deleted file mode 100644 index 229fbc6..0000000 --- a/test/prompttest_1.jl +++ /dev/null @@ -1,87 +0,0 @@ -using Revise # remove when this package is completed -using YiemAgent, GeneralUtils, JSON3, MQTTClient, Dates, UUIDs, DataStructures -using Base.Threads - -# ---------------------------------------------- 100 --------------------------------------------- # - -config = copy(JSON.parsefile("config.json")) - -instanceInternalTopic = config[:serviceInternalTopic][:mqtttopic] * "/1" - -client, connection = MakeConnection(config[:mqttServerInfo][:broker], - config[:mqttServerInfo][:port]) - -receiveUserMsgChannel = Channel{Dict}(4) -receiveInternalMsgChannel = Channel{Dict}(4) - -msgMeta = GeneralUtils.generate_msgMeta( - "N/A", - replyTopic = config[:servicetopic][:mqtttopic] # ask frontend reply to this instance_chat_topic - ) - -agentConfig = Dict( - :mqttServerInfo=> config[:mqttServerInfo], - :receivemsg=> Dict( - :prompt=> config[:servicetopic][:mqtttopic], # topic to receive prompt i.e. frontend send msg to this topic - :internal=> instanceInternalTopic, - ), - :externalservice=> config[:externalservice], -) - -# Instantiate an agent -tools=Dict( # update input format - "askbox"=> Dict( - :description => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - :input => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - :output => "" , - :func => nothing, - ), - # "winestock"=> Dict( - # :description => "A handy tool for searching wine in your inventory that match the user preferences.", - # :input => """Input is a JSON-formatted string that contains a detailed and precise search query.{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}""", - # :output => """Output are wines that match the search query in JSON format.""", - # :func => ChatAgent.winestock, - # ), - "finalanswer"=> Dict( - :description => "Useful for when you are ready to recommend wines to the user.", - :input => """{\"finalanswer\": \"some text\"}.{\"finalanswer\": \"I recommend Zena Crown Vista\"}""", - :output => "" , - :func => nothing, - ), - ) - - a = YiemAgent.sommelier( - receiveUserMsgChannel, - receiveInternalMsgChannel, - agentConfig, - name= "assistant", - id= "testingSessionID", # agent instance id - tools=tools, - ) - - - - - - - -input = -OrderedDict{String, Any}(:question => "Hello, I would like a get a bottle of wine", :thought_1 => "It's great that the user is looking for a bottle of wine. To give them a personalized recommendation, I need to know more about their preferences.", :action_1 => Dict{String, Any}(:name => "chatbox", :input => "What occasion are you planning to use this wine for?"), :observation_1 => "We are holding a wedding party", :thought_2 => "A wedding party is a great occasion for a special bottle of wine. I need to know what type of food will be served, and how much the user is willing to spend.", :action_2 => Dict{String, Any}(:name => "chatbox", :input => "What type of food will you be serving at the wedding?"), :observation_2 => "It will be Thai dishes.", :thought_3 => "The type of wine that pairs well with Thai dishes is usually a crisp and refreshing white wine, but I also need to consider the budget and personal preferences.", :action_3 => Dict{String, Any}(:name => "chatbox", :input => "How much are you willing to spend on this bottle of wine?"), :observation_3 => "I would spend up to 50 bucks.", :thought_4 => "I have a good idea of the occasion, food, and budget. Now I need to know what type of wine the user is looking for.", :action_4 => Dict{String, Any}(:name => "chatbox", :input => "What type of wine are you usually looking for? Red, White, Sparkling, Rose, Dessert or Fortified?"), :observation_4 => "I like full-bodied Red wine with low tannin.", :thought_5 => "Now that I have all the necessary information, I can start searching for a suitable wine in our inventory.", :action_5 => Dict{String, Any}(:name => "winestock", :input => "red wine with low tannins"), :observation_5 => "I found the following wines in our stock: \n{\n 1: El Enemigo Cabernet Franc 2019\n2: Tantara Chardonnay 2017\n\n}\n", :thought_6 => "Now that I have the information about the wine, it's time to make a recommendation.", :action_6 => Dict{String, Any}(:name => "recommendbox", :input => "El Enemigo Cabernet Franc 2019"), :observation_6 => "I don't like the one you recommend. I want dry wine.") - - -result = YiemAgent.jsoncorrection(a, input) - - - - - - - - - - - - - - - diff --git a/test/prompttest_2.jl b/test/prompttest_2.jl deleted file mode 100644 index 58f3e2d..0000000 --- a/test/prompttest_2.jl +++ /dev/null @@ -1,119 +0,0 @@ -using Revise -using YiemAgent, GeneralUtils, JSON3, DataStructures, LibPQ -using SQLLLM - - -# _prompt = -# """ -# You are a helpful assistant. -# answer the following question: -# From the following CSV text: -# "{\"tabledescription\":[\"The customer table stores information about customers. It includes details such as first name, last name, display name, username, password, gender, country, telephone number, email, birthdate, additional_search_term, other attributes (in JSON format) and a description.\",\"The wine table stores information about different wines. It includes details namely id, name, brand, manufacturer, region, country, wine_type, grape_variety, serving_temperature, intensity, sweetness, tannin, acidity, fizziness, additional_search_term, other attributes (in JSON format) and a description.\",\"The wine_food table represents the association between wines and food items. It estab" ⋯ 477 bytes ⋯ "ed to retailer names, usernames, passwords, addresses, contact persons, telephone numbers, email addresses, additional_search_term, other attributes (in JSON format) and a description.\",\"The retailer_wine table represents the relationship between retailers and wines. It stores information about the wines available from which retailers, including vintage, their price, and the currency.\",\"The retailer_food table represents the relationship between retailers and food items. It stores information about the food items available from which retailers, including their price and the currency.\"],\"tablename\":[\"customer\",\"wine\",\"wine_food\",\"food\",\"retailer\",\"retailer_wine\",\"retailer_food\"]}" -# What is the description of table wine? -# """ - -# prompt = YiemAgent.formatLLMtext_llama3instruct("system", _prompt) -# @show prompt -# msgMeta = Dict(:requestResponse => nothing, -# :msgPurpose => nothing, -# :receiverId => nothing, -# :getPost => nothing, -# :msgId => "4c7111e0-c30e-44c3-8f85-1c8b3f03a8be", -# :acknowledgestatus => nothing, -# :replyToMsgId => nothing, -# :msgFormatVersion => nothing, -# :mqttServerInfo => Dict(:port => 1883, :broker => "mqtt.yiem.cc"), -# :sendTopic => "/loadbalancer/requestingservice", -# :receiverName => "text2textinstruct", -# :replyTopic => nothing, -# :senderName => "decisionMaker", -# :senderSelfnote => nothing, -# :senderId => "testingSessionID", -# :timeStamp => "2024-05-04T08:06:23.561" -# ) - -# outgoingMsg = Dict( -# :msgMeta=> msgMeta, -# :payload=> Dict( -# :text=> prompt, -# ) -# ) - - - -# _response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg) -# result = _response[:response][:text] - - - - - - - -DBconnection = LibPQ.Connection("host=192.168.88.12 port=5432 dbname=yiem_wine_assistant user=yiem password=yiem@Postgres_0.0") - -tableinfo, df1, df2, df3 = SQLLLM.tableinfo(DBconnection, "wine") - - -_prompt = -""" -You are a helpful assistant helping to answer user question from a database table. - -$tableinfo - -Are there any chardonnay? -""" - -prompt = YiemAgent.formatLLMtext_llama3instruct("system", _prompt) -@show prompt -msgMeta = Dict(:requestResponse => nothing, - :msgPurpose => nothing, - :receiverId => nothing, - :getPost => nothing, - :msgId => "4c7111e0-c30e-44c3-8f85-1c8b3f03a8be", - :acknowledgestatus => nothing, - :replyToMsgId => nothing, - :msgFormatVersion => nothing, - :mqttServerInfo => Dict(:port => 1883, :broker => "mqtt.yiem.cc"), - :sendTopic => "/loadbalancer/requestingservice", - :receiverName => "text2textinstruct", - :replyTopic => nothing, - :senderName => "decisionMaker", - :senderSelfnote => nothing, - :senderId => "testingSessionID", - :timeStamp => "2024-05-04T08:06:23.561" - ) - -outgoingMsg = Dict( - :msgMeta=> msgMeta, - :payload=> Dict( - :text=> prompt, - ) -) - - - -_response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg) -result2 = _response[:response][:text] - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/test1.jl b/test/runtest.jl similarity index 100% rename from test/test1.jl rename to test/runtest.jl diff --git a/test/runtests.jl b/test/runtests.jl deleted file mode 100644 index e3da741..0000000 --- a/test/runtests.jl +++ /dev/null @@ -1,223 +0,0 @@ - -using JSON, Dates, UUIDs, PrettyPrinting, Base64, NATS, HTTP -using GeneralUtils, msghandler - -config = JSON.parsefile("./appconfig.json") - -agent_conn = NATS.connect(config["nats_server_info"]["url"]) - -function text2text_instruct_llm(sender_id::String, openai_msg::Dict{String, Any}) - payloads = [("msg", openai_msg, "dictionary")] # List of tuples - _, msg_envelope_json_str = msghandler.smartpack( - config["externalservice"]["servicesloadbalancer"]["nats"], - payloads; - sender_id=sender_id, - msg_purpose="text2text", - broker_url=config["nats_server_info"]["url"], - fileserver_url=config["externalservice"]["fileserver"]["url"]) - - reply = NATS.request(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - msg_envelope_json_str, timeout=120) - - incoming_env_json_str = String(reply.payload) - incoming_env = msghandler.smartunpack(incoming_env_json_str) - _llm_response = incoming_env["payloads"][1][2] - llm_response = _llm_response["choices"][1]["message"]["content"] - return llm_response -end - - - - -# 1. Read local file and encode to base64 string -image1_path = "test/large_image.png" -image1_bytes = read(image1_path) -image1_base64_string = base64encode(image1_bytes) - -# 2. Match the MIME type according to your file extension (e.g., png, jpeg) -mime_type = "image/png" -data1_uri = "data:$(mime_type);base64,$(image1_base64_string)" - -# 3. Construct payload with the Data URI -openai_msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => [ - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - ], - "temperature" => 0.7 -) - -llm_response = text2text_instruct_llm(openai_msg) - - - -# 1. Read local file and encode to base64 string -image2_path = "test/large_image.png" -image2_bytes = read(image2_path) -image2_base64_string = base64encode(image2_bytes) - -# 2. Match the MIME type according to your file extension (e.g., png, jpeg) -mime_type = "image/png" -data2_uri = "data:$(mime_type);base64,$(image2_base64_string)" - -systemmsg = - """ - # Store Policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # Store Guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - # Situation - Your customer is coming into the store - - # Role - Your name is Janie. You are a helpful sommelier for website-based Yiem Wine's wine store. You are working under your mentor supervision. - - # Objective - 1. Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - 2. Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # Responsibility Includes - 1. According to the store's policy and guidelines, make an informed decision about what you need to do to achieve the objective - 2. Keep the conversation with the customer going smoothly - 3. Obey your mentor's suggestions. - - # Responsibility Does NOT Include - - 1. Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - 2. Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - 3. Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # You should then respond to the user with interleaving plan, action_name, action_input - 1) plan: Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) action_name: (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name - 3) action_input: The input to the action you are about to perform according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # You should only respond in JSON format as described below - "plan": "...", - "action_name": "...", - "action_input": "..." - - # Available Actions - - **CHAT_BOX** which you can use to talk with the user. - - **SEARCH_WINE_DATABASE** allows you to check information about wines you want in your inventory's database. The input is text that specify supported search criteria includeing: retailer_name, wine price, winery, name, vintage, region, country, type, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. - - Example query 1: "Dry, full-bodied red wine from 1) region: Burgundy, country: France or 2) region: Tuscany, country: Italy. Grape varietal: Merlot or Syrah. price 100 to 1000 USD." - - Example query 2: "Red or white wine, medium tannin, price under 700 USD" - - Example query 3: "white wine, region: Tuscany or Bordeaux, country: Italy or France - - **PRESENT_WINE_GUIDELINE** which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - - **END_CONVER_GUIDELINE** which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - """ - - -openai_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 know this wine? Just give me brief intro."), - ] - ), - Dict( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => - """ - " I will greet the customer warmly as Janie, acknowledge their request to find a similar wine for their wedding party based on the image, identify the wine type and country (Italian Sparkling Wine), and then use the SEARCH_WINE_DATABASE action to search the inventory for suitable options.\n CHAT_BOX\n Hello! I'm Janie, and I'd be delighted to help you find the perfect wine for your wedding party. That beautiful wine in the image appears to be an Italian sparkling wine, which is wonderful for a celebration like a wedding! Since you have an unlimited budget, I can certainly look for some truly exceptional options. To start, I will check our inventory for similar Italian sparkling wines that are perfect for a wedding celebration. User response in the next message " - """ - ), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "ok"), - ] - ), - ], - "temperature" => 0.7 -) - -llm_response = text2text_instruct_llm(openai_msg) - - - - - -# ---------------------------------------------- 100 --------------------------------------------- # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -- 2.52.0 From d63ddd3d27b65832bb5e2d46ed1a4968090cc711 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 09:24:41 +0700 Subject: [PATCH 035/112] update --- src/utils.jl | 85 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index 0612a78..a958535 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -74,36 +74,39 @@ end """ - prepareContext(state::agentState) -> Vector{agentMessage} + prepareContext(state::agentState) -> agentContext -Returns a deep copy of the messages from the given `agentState`, ready -to be sent to the LLM. Override this function to inject additional -context — such as retrieved documents, current time, user preferences, -or any other relevant information — into the message list before -formatting and calling the LLM. +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`. -By default, returns an exact copy of `state.messages` without -modification. +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 +- `state::agentState`: The current agent state containing conversation history, + system prompt, tools, and other configuration # Returns -- `Vector{agentMessage}`: A deep copy of the messages to be sent to the LLM +- `agentContext`: An `agentContext` containing the prepared system prompt, + messages, and tools to be sent to the LLM # Examples ```julia -# Default: returns a deep copy of messages -prepareContext(state) == deepcopy(state.messages) +# Default: returns an agentContext with deep copies of system prompt, messages, and tools +prepareContext(state).messages == deepcopy(state.messages) -# Override to inject system context: -# function Base.prepareContext(state::agentState) +# Override to filter tools and inject system context: +# function prepareContext(state::agentState) # msgs = deepcopy(state.messages) -# pushfirst!(msgs, textMessage("system", "You are a helpful assistant.")) -# return msgs +# sysPrompt = state.systemPrompt * "\\nCurrent time: $(now())" +# tools = filter(t -> contains(t.description, "wine"), state.tools) +# return agentContext(sysPrompt, msgs, tools) # end ``` -""" #WORKING +""" function prepareContext(state::agentState)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description @@ -121,9 +124,38 @@ function prepareContext(state::agentState)::agentContext end -""" convert preparedcontext into openai message format ready to be used by LLM """ -function formatMsgForLLM(preparedContext::Vector{agentMessage})::Dict{String, Any} + 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)::Dict{String, Any} + """ openai message format example msg = Dict( "model" => "gemma-4-E4B-it-UD-Q4_K_XL", @@ -140,7 +172,7 @@ function formatMsgForLLM(preparedContext::Vector{agentMessage})::Dict{String, An Dict("type" => "text", "text" => "Do you have something similar to the one in the image?"), Dict( "type" => "image_url", - "image_url" => Dict("url" => data1_uri) + "image_url" => Dict("url" => data_uri) ) ] ), @@ -161,9 +193,18 @@ function formatMsgForLLM(preparedContext::Vector{agentMessage})::Dict{String, An ) """ - messages = Dict{String, Any}[] + messages = Vector{Dict{String, Any}}() - for msg in preparedContext + # System prompt as system message + if !isempty(ctx.systemPrompt) + push!(messages, Dict( + "role" => "system", + "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] + )) + end + + # Conversation messages + for msg in ctx.messages if msg isa userMessage push!(messages, _userMessageToOpenAI(msg)) elseif msg isa assistantMessage -- 2.52.0 From b08121ee508cd7c2fb753143eaa2c4fbe91bdab4 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 09:37:27 +0700 Subject: [PATCH 036/112] update --- src/agentCore.jl | 3 +++ src/utils.jl | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 311d00e..d19313d 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -176,6 +176,9 @@ function _process_message(agent::yiemAgent)::assistantMessage # loop until llmCall() response didn't use tool calls final_response = nothing while true + #WORKING check + + # call agent.prepareContext() preparedContext = agent.prepareContext(agent._state) diff --git a/src/utils.jl b/src/utils.jl index a958535..fda9814 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,6 +1,7 @@ module utils -export clearhistory, availableWineToText, prepareContext +export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, _userMessageToOpenAI, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils -- 2.52.0 From 2de4fa07c12d5c055dd564c94133dbd37bb21a4d Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 10:19:55 +0700 Subject: [PATCH 037/112] update --- src/agentCore.jl | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index d19313d..21c7dfa 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -176,7 +176,22 @@ function _process_message(agent::yiemAgent)::assistantMessage # loop until llmCall() response didn't use tool calls final_response = nothing while true - #WORKING check + + """ example message in agent.inputChannel + 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) + ) + ] + ), + """ + + # check agent.inputChannel if there are, use OpenAiToUserMessage() to convert them into + # userMessage type and push to agent._state.messages. # call agent.prepareContext() -- 2.52.0 From 14bac755ea87596ca1e486f93e60b0abde415a9b Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 13:28:09 +0700 Subject: [PATCH 038/112] update --- src/agentCore.jl | 79 +++++++++++++++++++++++++++++++++++++++++++++--- src/type.jl | 40 ------------------------ 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 21c7dfa..4de1215 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export _agent_loop +export _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Serde @@ -184,14 +184,23 @@ function _process_message(agent::yiemAgent)::assistantMessage 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) + "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") ) ] ), """ - # check agent.inputChannel if there are, use OpenAiToUserMessage() to convert them into - # userMessage type and push to agent._state.messages. + # Drain inputChannel and convert OpenAI-format messages to userMessage type + while isready(agent.inputChannel) + raw_msg = take!(agent.inputChannel) + if raw_msg === :shutdown + # Re-emit shutdown signal for the loop to handle + put!(agent.inputChannel, :shutdown) + break + end + user_msg = OpenAiToUserMessage(raw_msg) + push!(agent._state.messages, user_msg) + end # call agent.prepareContext() @@ -1008,14 +1017,66 @@ end +""" + OpenAiToUserMessage(msg::Dict) -> userMessage +Converts an OpenAI-format message dictionary into a `userMessage` type. +Parses `content` blocks: `text` blocks become `textContent`, `image_url` blocks +have their data URI (`data:;base64,`) parsed via regex to extract the +base64 data and MIME type as separate `imageContent` fields. +The OpenAI-format dictionary: +``` +Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "..."), + Dict("type" => "image_url", "image_url" => Dict("url" => "data:image/png;base64,...")) + ] +) +``` +# Arguments +- `msg`: A dictionary with `"role"` and `"content"` keys in OpenAI format +# Returns +- `userMessage`: Instance with `content` as `Vector{messageContent}` +# Examples +```julia +msg = Dict("role" => "user", "content" => [Dict("type" => "text", "text" => "Hello")]) +OpenAiToUserMessage(msg) +# => userMessage("user", [textContent("Hello")], DateTime(...)) +``` +""" +function OpenAiToUserMessage(msg::Dict)::userMessage + content_blocks = Vector{messageContent}() + raw_content = get(msg, "content", Any[]) + if raw_content isa Vector + for block in raw_content + if block isa Dict + block_type = get(block, "type", "") + if block_type == "text" + text = get(block, "text", "") + push!(content_blocks, textContent(text)) + elseif block_type == "image_url" + image_url = get(block, "image_url", Dict()) + url = get(image_url, "url", "") + m = match(r"^data:([a-z0-9/_-]+);base64,(.+)$", url) + if m !== nothing + push!(content_blocks, imageContent(m.captures[2], m.captures[1])) + else + push!(content_blocks, imageContent(url, "image/png")) + end + end + end + end + end + return userMessage(content=content_blocks) +end @@ -1087,4 +1148,12 @@ end -end # end of module \ No newline at end of file + + + + + + + + +end # end of module diff --git a/src/type.jl b/src/type.jl index 264cdd7..f573e67 100644 --- a/src/type.jl +++ b/src/type.jl @@ -139,51 +139,11 @@ struct textContent <: messageContent # Plain text message content text::String # The text content end -""" -Create plain text message content. - -# Arguments -- `text::String`: The text content - -# Returns -- A new `textContent` instance - -# Examples -```julia -julia> content = textContent("Hello, world!") -textContent("Hello, world!") -``` -""" -function textContent(; text="") - return textContent(text) -end - struct imageContent <: messageContent # Image message content data::String # Base64-encoded image data mimeType::String # MIME type (e.g., "image/png") end -""" -Create image message content. - -# Arguments -- `data::String`: Base64-encoded image data -- `mimeType::String`: MIME type (e.g., "image/png") - -# Returns -- A new `imageContent` instance - -# Examples -```julia -julia> img = imageContent(data="base64data...", mimeType="image/png") -imageContent("base64data...", "image/png") -``` -""" -function imageContent(; data="", mimeType="") - return imageContent(data, mimeType) -end - - # ------------------------------------------------------------------------------------------------ # # Tool types # # ------------------------------------------------------------------------------------------------ # -- 2.52.0 From 197a7a0cb7a3dec3cd241b36faa92526a1f88853 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 13:59:13 +0700 Subject: [PATCH 039/112] update --- src/agentCore.jl | 3 +-- src/type.jl | 46 +++++++++++++++++----------------------------- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 4de1215..7031295 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -202,7 +202,6 @@ function _process_message(agent::yiemAgent)::assistantMessage push!(agent._state.messages, user_msg) end - # call agent.prepareContext() preparedContext = agent.prepareContext(agent._state) @@ -998,7 +997,7 @@ function executeToolCalls( hasSequential = false for tc in toolCalls for t in context.tools - if t.name == tc.name && get(t.executionMode, "parallel") == "sequential" + if t.name == tc.name && !t.parallelToolExecute hasSequential = true break end diff --git a/src/type.jl b/src/type.jl index f573e67..0da023f 100644 --- a/src/type.jl +++ b/src/type.jl @@ -13,6 +13,21 @@ struct Usage outputTokens::Int64 end +# ------------------------------------------------------------------------------------------------ # +# Message content types # +# ------------------------------------------------------------------------------------------------ # + +abstract type messageContent end # Base type for message content + +struct textContent <: messageContent # Plain text message content + text::String # The text content +end + +struct imageContent <: messageContent # Image message content + data::String # Base64-encoded image data + mimeType::String # MIME type (e.g., "image/png") +end + # ------------------------------------------------------------------------------------------------ # # Message types # @@ -128,22 +143,6 @@ function toolResultMessage(; role="tool", toolCallId="", toolName="", return toolResultMessage(role, toolCallId, toolName, content, details, usage, addedToolNames, isError, timestamp) end - -# ------------------------------------------------------------------------------------------------ # -# Message content types # -# ------------------------------------------------------------------------------------------------ # - -abstract type messageContent end # Base type for message content - -struct textContent <: messageContent # Plain text message content - text::String # The text content -end - -struct imageContent <: messageContent # Image message content - data::String # Base64-encoded image data - mimeType::String # MIME type (e.g., "image/png") -end - # ------------------------------------------------------------------------------------------------ # # Tool types # # ------------------------------------------------------------------------------------------------ # @@ -158,7 +157,7 @@ A tool available to the agent. - `parameters::TParameters`: Tool parameters schema (JSON schema) - `execute::Function`: Tool execution function - `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback -- `parallelExecute::Union{toolparallelExecute, Nothing}`: Override: run tool calls sequentially or in parallel +- `parallelToolExecute::Bool`: Override: run tool calls sequentially or in parallel # Returns - A new `agentTool` instance @@ -170,7 +169,7 @@ struct agentTool{TParameters, TDetails} # A tool available to the agent parameters::TParameters # Tool parameters schema (JSON schema) execute::Function # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback - parallelExecute::Union{toolparallelExecute, Nothing} # Override: run tool calls sequentially or in parallel + parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel end @@ -346,17 +345,6 @@ struct agentToolResult terminate::Bool end -""" -Function type for parallel execution override on a tool. - -# Arguments -- Context for parallel execution - -# Returns -- `agentToolCallBatch`: The result batch from parallel execution -""" -const toolparallelExecute = Function - """ Context passed to the `beforeToolCall` hook. -- 2.52.0 From 9e637f92bd99492d233a8f227a430d735938e09f Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 14:07:40 +0700 Subject: [PATCH 040/112] update --- src/type.jl | 61 +++++++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/type.jl b/src/type.jl index 0da023f..e659ca1 100644 --- a/src/type.jl +++ b/src/type.jl @@ -8,11 +8,36 @@ using GeneralUtils const Timestamp = DateTime -struct Usage +# ------------------------------------------------------------------------------------------------ # +# LLM model info # +# ------------------------------------------------------------------------------------------------ # + +struct modelCost # Model pricing per 1M tokens + input::Float64 # Price per 1M input tokens + output::Float64 # Price per 1M output tokens + cache_read::Float64 # Price per 1M cached read tokens + cache_write::Float64 # Price per 1M cache write tokens +end + +struct llmModel # LLM model configuration + id::String # Unique model identifier + name::String # Human-readable model name + api::Api # API type (parametric type) + provider::String # Provider name (e.g., "anthropic", "openai") + baseUrl::String # API endpoint base URL + reasoning::Bool # Whether the model supports chain-of-thought + input::Vector{String} # Supported input modalities (e.g., "text", "image") + cost::modelCost # Pricing information + contextWindow::Int64 # Maximum context length in tokens + maxTokens::Int64 # Maximum output tokens per completion +end + +struct llmUsage inputTokens::Int64 outputTokens::Int64 end + # ------------------------------------------------------------------------------------------------ # # Message content types # # ------------------------------------------------------------------------------------------------ # @@ -67,7 +92,7 @@ struct assistantMessage <: agentMessage # Message from the AI assistant api::String # API name used (e.g., "openai") provider::String # Provider name (e.g., "anthropic") model::String # Model identifier - usage::Usage # Token usage for this message + usage::llmUsage # Token usage for this message stopReason::String # Why generation stopped (e.g., "end_turn") errorMessage::Union{String, Nothing} # Error if generation failed timestamp::Timestamp # When the message was received @@ -82,7 +107,7 @@ Create a new assistant message. - `api::String`: API name used (e.g., "openai") - `provider::String`: Provider name (e.g., "anthropic") - `model::String`: Model identifier -- `usage::Usage`: Token usage for this message +- `usage::llmUsage`: Token usage for this message - `stopReason::String`: Why generation stopped (e.g., "end_turn") - `errorMessage::Union{String, Nothing}`: Error if generation failed - `timestamp::Timestamp`: When the message was received @@ -97,7 +122,7 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en ``` """ function assistantMessage(; role="assistant", content=Vector{messageContent}(), - api="", provider="", model="", usage=Usage(0, 0), stopReason="end_turn", + api="", provider="", model="", usage=llmUsage(0, 0), stopReason="end_turn", errorMessage=nothing, timestamp=now()) return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) end @@ -108,7 +133,7 @@ struct toolResultMessage <: agentMessage # Result returned from a tool execut toolName::String # Name of the executed tool content::Vector{messageContent} # Tool output content details::Any # Additional tool-specific details - usage::Union{Usage, Nothing} # Token usage if applicable + usage::Union{llmUsage, Nothing} # Token usage if applicable addedToolNames::Union{Vector{String}, Nothing} # Tools added during execution isError::Bool # Whether the tool call resulted in an error timestamp::Timestamp # When the result was recorded @@ -123,7 +148,7 @@ Create a new tool result message. - `toolName::String`: Name of the executed tool - `content::Vector{messageContent}`: Tool output content - `details::Any`: Additional tool-specific details -- `usage::Union{Usage, Nothing}`: Token usage if applicable +- `usage::Union{llmUsage, Nothing}`: Token usage if applicable - `addedToolNames::Union{Vector{String}, Nothing}`: Tools added during execution - `isError::Bool`: Whether the tool call resulted in an error - `timestamp::Timestamp`: When the result was recorded @@ -279,26 +304,6 @@ struct prepareNextTurnContext # Context for preparing the end -struct modelCost # Model pricing per 1M tokens - input::Float64 # Price per 1M input tokens - output::Float64 # Price per 1M output tokens - cache_read::Float64 # Price per 1M cached read tokens - cache_write::Float64 # Price per 1M cache write tokens -end - -struct llmModel{Api} # LLM model configuration - id::String # Unique model identifier - name::String # Human-readable model name - api::Api # API type (parametric type) - provider::String # Provider name (e.g., "anthropic", "openai") - baseUrl::String # API endpoint base URL - reasoning::Bool # Whether the model supports chain-of-thought - input::Vector{String} # Supported input modalities (e.g., "text", "image") - cost::modelCost # Pricing information - contextWindow::Int64 # Maximum context length in tokens - maxTokens::Int64 # Maximum output tokens per completion -end - # ------------------------------------------------------------------------------------------------ # # Agent loop configuration & tool execution types # # ------------------------------------------------------------------------------------------------ # @@ -335,13 +340,13 @@ Result returned by tool execution before the `afterToolCall` hook. # Arguments - `content::Vector{messageContent}`: Tool output content - `details::Dict{Any,Any}`: Tool-specific details -- `usage::Union{Usage, Nothing}`: Token usage if applicable +- `usage::Union{llmUsage, Nothing}`: Token usage if applicable - `terminate::Bool`: Whether tool requests termination of the agent loop """ struct agentToolResult content::Vector{messageContent} details::Dict{Any,Any} - usage::Union{Usage, Nothing} + usage::Union{llmUsage, Nothing} terminate::Bool end -- 2.52.0 From 7b465baddd88d4f9bc1da409eb7e01dc7f9a31ea Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 14:31:33 +0700 Subject: [PATCH 041/112] update --- src/YiemAgent.jl | 4 ++-- src/agentCore.jl | 26 +++++++++++++------------- src/type.jl | 1 - 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index e021f3c..764574c 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -13,8 +13,8 @@ module YiemAgent include("utils.jl") using .utils - include("llmfunction.jl") - using .llmfunction + # include("llmfunction.jl") + # using .llmfunction include("agentCore.jl") using .agentCore diff --git a/src/agentCore.jl b/src/agentCore.jl index 7031295..6b96c57 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -3,7 +3,7 @@ module agentCore export _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde + DataFrames, Serde, Base.Threads using GeneralUtils using ..type, ..utils, ..llmfunction @@ -34,41 +34,41 @@ julia> # Called automatically by yiemAgent constructor """ function _agent_loop(agent::yiemAgent) try - processing_task = nothing + processingTask = nothing """ cases: 1) agent -> idle, user msg -> nothing - typeof(processing_task) == Nothing + typeof(processingTask) == Nothing agent._state.activeRun -> false agent.inputChannel -> nothing agent.followUpChannel -> nothing 2) agent -> idle, user msg -> new msg - typeof(processing_task) == Nothing + typeof(processingTask) == Nothing agent._state.activeRun -> false agent.inputChannel -> new msg agent.followUpChannel -> nothing 3) agent -> running, user msg -> nothing - typeof(processing_task) == Task, istaskdone(processing_task) -> false + typeof(processingTask) == Task, istaskdone(processingTask) -> false agent._state.activeRun -> true agent.inputChannel -> nothing agent.followUpChannel -> nothing 4) agent -> running, user msg -> new msg - typeof(processing_task) == Task, istaskdone(processing_task) -> false + typeof(processingTask) == Task, istaskdone(processingTask) -> false agent._state.activeRun -> true agent.inputChannel -> new msg agent.followUpChannel -> nothing 5) agent -> running, user msg -> nothing, user msg follow up -> new msg - typeof(processing_task) == Task, istaskdone(processing_task) -> false + typeof(processingTask) == Task, istaskdone(processingTask) -> false agent._state.activeRun -> true agent.inputChannel -> nothing agent.followUpChannel -> new msg 6) agent -> idle, user msg -> nothing - typeof(processing_task) == Task, istaskdone(processing_task) -> true + typeof(processingTask) == Task, istaskdone(processingTask) -> true agent._state.activeRun -> false agent.inputChannel -> nothing agent.followUpChannel -> nothing @@ -108,12 +108,12 @@ function _agent_loop(agent::yiemAgent) # start _process_message loop if agent._state.activeRun == false # Dispatch message through the processing pipeline - processing_task = @spawn _process_message(agent) + processingTask = Threads.@spawn _process_message(agent) agent._state.activeRun = true end # during agent runs, check followUp message after _process_message() is done - if typeof(processing_task) == Task && istaskdone(processing_task) == false + if typeof(processingTask) == Task && istaskdone(processingTask) == false # if followUp message available, add them all to agent.inputChannel if isready(agent.followUpChannel) while isready(agent.followUpChannel) @@ -123,7 +123,7 @@ function _agent_loop(agent::yiemAgent) end continue # continue to process user message in the next loop - elseif typeof(processing_task) == Task && istaskdone(processing_task) == true + elseif typeof(processingTask) == Task && istaskdone(processingTask) == true # if agent runs is done but followUpChannel has messages, discard all message in it. # when agent work is done it should not accept follow up msg. # user should put new message in inputChannel instead @@ -132,10 +132,10 @@ function _agent_loop(agent::yiemAgent) _ = take!(agent.followUpChannel) end end - result = fetch(processing_task) + result = fetch(processingTask) put!(agent.outputChannel, result) agent._state.activeRun = false # reset - processing_task = nothing # reset + processingTask = nothing # reset end end catch e diff --git a/src/type.jl b/src/type.jl index e659ca1..c803289 100644 --- a/src/type.jl +++ b/src/type.jl @@ -22,7 +22,6 @@ end struct llmModel # LLM model configuration id::String # Unique model identifier name::String # Human-readable model name - api::Api # API type (parametric type) provider::String # Provider name (e.g., "anthropic", "openai") baseUrl::String # API endpoint base URL reasoning::Bool # Whether the model supports chain-of-thought -- 2.52.0 From 4df10430326f016e1792bf2e684af30b8ddff34b Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 15:18:42 +0700 Subject: [PATCH 042/112] update --- src/type.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/type.jl b/src/type.jl index c803289..4f987c4 100644 --- a/src/type.jl +++ b/src/type.jl @@ -3,7 +3,7 @@ module type run_agent, take_response, follow_up, stop_agent -using Dates, UUIDs, DataStructures, JSON, NATS +using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads using GeneralUtils const Timestamp = DateTime @@ -587,7 +587,7 @@ carrying the full context through the call chain. # Examples ```julia # After prepareToolCall succeeds, the agent holds a preparedToolCall -prep = preparedToolCall( + prep = preparedToolCall( tool, # agentTool found in context.tools toolCall, # {id: "call_1", name: "search_wine", arguments: "{\"query\": \"red wine\"}"} validatedArgs # Dict("query" => "red wine") -- 2.52.0 From 02e93cae57609587938a0ceee5a762a1b3eedc7e Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 15:47:27 +0700 Subject: [PATCH 043/112] update --- src/type.jl | 47 +++++++++++++++++++++++++++++++++++------------ src/utils.jl | 8 ++++---- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/type.jl b/src/type.jl index 4f987c4..46f95bc 100644 --- a/src/type.jl +++ b/src/type.jl @@ -1,6 +1,29 @@ -module type - export agentContext, yiemAgent, - run_agent, take_response, follow_up, stop_agent + module type + export Timestamp, + # Abstract types + messageContent, agentMessage, agent, + # Model types + modelCost, llmModel, llmUsage, + # Message content types + textContent, imageContent, + # Message types + userMessage, assistantMessage, toolResultMessage, + # Tool types + agentTool, + # Context types + agentContext, agentState, agentToolCall, prepareNextTurnContext, + # Loop & execution types + agentLoopConfig, abortSignal, agentToolResult, + assistantMsgCtx, afterCtx, + # Event types + toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, + # Agent + yiemAgent, + # Tool call lifecycle types + preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, + agentToolCallBatch, + # Functions (defined elsewhere) + run_agent, take_response, follow_up, stop_agent using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads @@ -597,7 +620,7 @@ carrying the full context through the call chain. struct preparedToolCall tool::agentTool # The resolved tool definition from the context toolCall::agentToolCall # The original tool call from the assistant - args::any # Validated (and coerced) argument values + args::Any # Validated (and coerced) argument values end """ @@ -615,7 +638,7 @@ corrected arguments after a validation failure. # Fields - `result::agentToolResult`: The pre-computed tool result -- `isError::bool`: Whether this outcome represents an error +- `isError::Bool`: Whether this outcome represents an error # Examples ```julia @@ -634,7 +657,7 @@ immediateOutcome( """ struct immediateOutcome result::agentToolResult # The pre-computed tool result - isError::bool # Whether this outcome represents an error + isError::Bool # Whether this outcome represents an error end """ @@ -649,7 +672,7 @@ whether to transform it or replace it entirely. # Fields - `result::agentToolResult`: The tool's execution result -- `isError::bool`: Whether execution raised an error +- `isError::Bool`: Whether execution raised an error # Examples ```julia @@ -668,7 +691,7 @@ executedOutcome( """ struct executedOutcome result::agentToolResult # The tool's execution result - isError::bool # Whether execution raised an error + isError::Bool # Whether execution raised an error end """ @@ -689,7 +712,7 @@ and swappable. # Fields - `toolCall::agentToolCall`: The original tool call reference - `result::agentToolResult`: The final tool result (post-afterToolCall) -- `isError::bool`: Whether the call failed or was blocked +- `isError::Bool`: Whether the call failed or was blocked # Examples ```julia @@ -703,7 +726,7 @@ finalizedOutcome(tc, agentToolResult(content, details, usage, true), false) struct finalizedOutcome toolCall::agentToolCall # The original tool call reference result::agentToolResult # The final tool result (post-afterToolCall) - isError::bool # Whether the call failed or was blocked + isError::Bool # Whether the call failed or was blocked end """ @@ -749,8 +772,8 @@ agentToolCallBatch(toolResultMessage[], false) ``` """ struct agentToolCallBatch - messages::vector{toolResultMessage} # Tool result messages for this batch - terminate::bool # Whether the batch should terminate the loop + messages::Vector{toolResultMessage} # Tool result messages for this batch + terminate::Bool # Whether the batch should terminate the loop end diff --git a/src/utils.jl b/src/utils.jl index fda9814..0c92f43 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -29,10 +29,10 @@ 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"] = "" + # empty!(a.chathistory) + # empty!(a.memory["shortmem"]) + # empty!(a.memory["events"]) + # a.memory["chatbox"] = "" end -- 2.52.0 From 0f16e045b9c0365454fe5360e27e1a95408917df Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 15:54:36 +0700 Subject: [PATCH 044/112] update --- src/agentCore.jl | 24 ++++++++++++------------ src/api.jl | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 6b96c57..0ee5916 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -5,7 +5,7 @@ export _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Serde, Base.Threads using GeneralUtils -using ..type, ..utils, ..llmfunction +using ..type, ..utils # ---------------------------------------------- 100 --------------------------------------------- # @@ -406,7 +406,7 @@ julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}() true ``` """ -function shouldTerminate(batches::vector{finalizedOutcome})::bool +function shouldTerminate(batches::Vector{finalizedOutcome})::Bool return !isempty(batches) && all(b -> b.result.terminate, batches) end @@ -513,8 +513,8 @@ function prepareToolCall( assistantMsg::assistantMessage, toolCall::agentToolCall, config::agentLoopConfig, - signal::union{nothing,abortSignal}, -)::union{preparedToolCall,immediateOutcome} + signal::Union{Nothing, abortSignal}, +)::Union{preparedToolCall,immediateOutcome} tool = find(t -> t.name == toolCall.name, context.tools) if tool === nothing @@ -588,7 +588,7 @@ executePreparedToolCall(prep, nothing, emit) """ function executePreparedToolCall( prep::preparedToolCall, - signal::union{nothing,abortSignal}, + signal::Union{Nothing,abortSignal}, emit::Function, )::executedOutcome @@ -678,7 +678,7 @@ function finalizeExecutedToolCall( prep::preparedToolCall, executed::executedOutcome, config::agentLoopConfig, - signal::union{nothing,abortSignal}, + signal::Union{Nothing,abortSignal}, )::finalizedOutcome result = executed.result @@ -795,9 +795,9 @@ executeToolCallsSequential(ctx, msg, [deployTc], config, nothing, emit) function executeToolCallsSequential( context::agentContext, assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, + toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::union{nothing,abortSignal}, + signal::Union{Nothing,abortSignal}, emit::Function, )::agentToolCallBatch @@ -888,9 +888,9 @@ executeToolCallsParallel(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit) function executeToolCallsParallel( context::agentContext, assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, + toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::union{nothing,abortSignal}, + signal::Union{Nothing,abortSignal}, emit::Function, )::agentToolCallBatch @@ -988,9 +988,9 @@ executeToolCalls(ctx, msg, [searchTc, fetchTc], configSequential, nothing, emit) function executeToolCalls( context::agentContext, assistantMsg::assistantMessage, - toolCalls::vector{agentToolCall}, + toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::union{nothing,abortSignal}, + signal::Union{Nothing,abortSignal}, emit::Function, )::agentToolCallBatch diff --git a/src/api.jl b/src/api.jl index 9412301..2ec3c6a 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,7 +5,7 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Serde using GeneralUtils -using ..type, ..util, ..llmfunction +using ..type, ..utils, ..llmfunction # ---------------------------------------------- 100 --------------------------------------------- # -- 2.52.0 From 131973edd4ece08cad46f36e4b6cd7edd596e793 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 15:55:37 +0700 Subject: [PATCH 045/112] update --- src/api.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.jl b/src/api.jl index 2ec3c6a..9db183f 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,7 +5,7 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Serde using GeneralUtils -using ..type, ..utils, ..llmfunction +using ..type, ..utils # ---------------------------------------------- 100 --------------------------------------------- # -- 2.52.0 From 8e356f06bcc58b287bae2bf6378ec7e4d682d11e Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 17:48:50 +0700 Subject: [PATCH 046/112] update --- src/api.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.jl b/src/api.jl index 9db183f..ec8ece2 100644 --- a/src/api.jl +++ b/src/api.jl @@ -117,7 +117,7 @@ julia> stop_agent(agent) function stop_agent(agent::yiemAgent) put!(agent.inputChannel, :shutdown) try - fetch(agent._task) + fetch(agent._agent_loop) catch e if e isa TaskFailedException rethrow(e) -- 2.52.0 From e60cbc67c4edc09f52f482d9da3e233e70675cbe Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 7 Aug 2026 23:13:13 +0700 Subject: [PATCH 047/112] update --- docs/loadtools.md | 92 +++++++++++++++++++++++++ src/YiemAgent.jl | 3 + src/tools/get_weather.jl | 59 ++++++++++++++++ src/tools/registry.jl | 142 +++++++++++++++++++++++++++++++++++++++ src/type.jl | 58 ++++++++++++++-- 5 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 docs/loadtools.md create mode 100644 src/tools/get_weather.jl create mode 100644 src/tools/registry.jl diff --git a/docs/loadtools.md b/docs/loadtools.md new file mode 100644 index 0000000..c222248 --- /dev/null +++ b/docs/loadtools.md @@ -0,0 +1,92 @@ +# Dynamic Tool Loading + +Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory without hardcoding filenames in the main module. + +## How It Works + +1. `src/tools/registry.jl` defines a `load_tools(dir::String)` function that scans a directory for `.jl` files +2. Each tool file must define a single function: `get_tool() :: agentTool` +3. `load_tools()` sorts files alphabetically, includes each one, calls `get_tool()`, and registers the result +4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent` + +## Directory Structure + +``` +src/ +├── tools/ +│ ├── registry.jl # Tool loader (do not edit) +│ ├── get_weather.jl # Your tool +│ └── query_db.jl # Another tool +├── type.jl +├── utils.jl +├── agentCore.jl +├── api.jl +└── YiemAgent.jl +``` + +## Creating a Tool + +Each `.jl` file in `src/tools/` must define `get_tool()` returning an `agentTool`: + +```julia +# src/tools/get_weather.jl +function get_tool() :: agentTool + return agentTool( + name = "get_weather", + label = "Weather Lookup", + description = "Fetch current weather and forecast for a given city.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City and country"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") + ), + "required" => ["city"] + ), + execute = (toolCallId, args, signal, onPartialResult) -> begin + city = args["city"] + return agentToolResult( + [textContent("Weather in $(city): Sunny, 22C")], + Dict{Any,Any}(), nothing, false + ) + end, + prepareArguments = nothing, + parallelToolExecute = false + ) +end +``` + +No `module` wrapper needed — the registry includes each file in the current module scope so all types (`agentTool`, `textContent`, `agentToolResult`, etc.) resolve correctly. + +## Loading Tools + +```julia +using .YiemAgent +using .YiemAgent: toolRegistry + +# Load all tool files from src/tools/ +tools = load_tools(joinpath(@__DIR__, "src", "tools")) + +# Create agent with loaded tools +agent = yiemAgent( + systemPrompt = "You are a helpful assistant.", + model = my_model, + tools = tools, + llmCall = my_llm_call, + agentEventSink = my_event_sink +) +``` + +## Available Functions + +| Function | Description | +|----------|-------------| +| `load_tools(dir::String)` | Scan directory and load all `.jl` tool files | +| `register_tool(tool::agentTool)` | Register a single tool into the global registry | +| `get_tools()` | Get deep copy of all registered tools | +| `list_tools()` | List all registered tools as `(name, label)` pairs | +| `clear_tools()` | Clear the global registry | + +## File Loading Order + +Files are sorted alphabetically before loading, so `01_database.jl` loads before `02_weather.jl`. This ensures deterministic registration order. diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 764574c..765807e 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -13,6 +13,9 @@ module YiemAgent include("utils.jl") using .utils + include("tools/registry.jl") + using .toolRegistry + # include("llmfunction.jl") # using .llmfunction diff --git a/src/tools/get_weather.jl b/src/tools/get_weather.jl new file mode 100644 index 0000000..808586a --- /dev/null +++ b/src/tools/get_weather.jl @@ -0,0 +1,59 @@ +""" +Execute the get_weather tool. + +# Arguments +- `toolCallId::String`: Unique identifier for this tool call +- `args::Dict{String,Any}`: Parsed arguments from the LLM +- `signal::Union{Nothing,abortSignal}`: Optional abort signal +- `onPartialResult::Function`: Callback for streaming partial results + +# Returns +- `agentToolResult`: Result content with weather data +""" +function execute_tool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) :: agentToolResult + city = get(args, "city", "") + units = get(args, "units", "celsius") + + # Validate required arguments + if isempty(city) + return agentToolResult( + [textContent("Error: 'city' argument is required.")], + Dict{Any,Any}(), nothing, false + ) + end + + # Simulate weather fetch — replace with actual API call + # You can call onPartialResult() here for streaming progress updates: + # onPartialResult(Dict("status" => "Fetching weather data...")) + # onPartialResult(Dict("status" => "Processing...")) + + temp = units == "fahrenheit" ? "72" : "22" + unit_symbol = units == "celsius" ? "°C" : "°F" + + return agentToolResult( + [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], + Dict{Any,Any}(), nothing, false + ) +end + +""" +Define and return the get_weather agentTool. +""" +function get_tool() :: agentTool + return agentTool( + name = "get_weather", + label = "Weather Lookup", + description = "Fetch current weather and forecast for a given city.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius", "description" => "Temperature scale") + ), + "required" => ["city"] + ), + execute = execute_tool, # reference the function defined above + prepareArguments = nothing, + parallelToolExecute = false + ) +end diff --git a/src/tools/registry.jl b/src/tools/registry.jl new file mode 100644 index 0000000..de121fd --- /dev/null +++ b/src/tools/registry.jl @@ -0,0 +1,142 @@ +module toolRegistry + +export load_tools, register_tool, get_tools, list_tools, clear_tools + +using ..type + +# Global registry — populated at runtime by load_tools() or register_tool() +const _registry = Vector{agentTool}() + +""" +Load all tool modules from a directory. + +Scans `dir` for `.jl` files. Each file must define a function named +`get_tool() :: agentTool`. Files are sorted alphabetically so tool +registration order is deterministic. + +# Tool file format +Each `.jl` file defines one function `get_tool()` that returns an `agentTool`: + +```julia +# src/tools/get_weather.jl +function get_tool() :: agentTool + return agentTool( + name = "get_weather", + label = "Weather Lookup", + description = "Fetch current weather and forecast for a given city.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City and country"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") + ), + "required" => ["city"] + ), + execute = (toolCallId, args, signal, onPartialResult) -> begin + city = args["city"] + return agentToolResult( + [textContent("Sunny, 22C in $(city)")], + Dict{Any,Any}(), nothing, false + ) + end, + prepareArguments = nothing, + parallelToolExecute = false + ) +end +``` + +# Arguments +- `dir::String`: Directory path to scan for `.jl` tool files + +# Returns +- `Vector{agentTool}`: All loaded tools + +# Errors +- Throws `ArgumentError` if a tool file does not define a `get_tool` function +""" +function load_tools(dir::String)::Vector{agentTool} + if !isdir(dir) + throw(ArgumentError("Tool directory does not exist: $dir")) + end + + tools = agentTool[] + jl_files = filter(f -> endswith(f, ".jl"), readdir(dir)) + sort!(jl_files) + + for filename in jl_files + filepath = joinpath(dir, filename) + println("[toolRegistry] Loading tool from: $filepath") + + # Include the file in the current module scope so all types resolve + # (agentTool, textContent, agentToolResult, etc. are all available) + include(filepath) + + # Validate that get_tool was defined (include() places it in current module scope) + if !isdefined(:get_tool) + throw(ArgumentError( + "Tool file $(filepath) does not define a `get_tool()` function. " * + "Each tool file must define: function get_tool() :: agentTool ... end" + )) + end + + # Call get_tool() — it runs in current scope where types are visible + tool = get_tool() + if !(tool isa agentTool) + throw(ArgumentError( + "get_tool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" + )) + end + + push!(_registry, tool) + push!(tools, tool) + println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") + end + + return tools +end + +""" +Register a single agentTool into the global registry. + +# Arguments +- `tool::agentTool`: The tool to register + +# Returns +- `Vector{agentTool}`: Updated registry +""" +function register_tool(tool::agentTool)::Vector{agentTool} + push!(_registry, tool) + println("[toolRegistry] Registered tool: $(tool.name)") + return _registry +end + +""" +Get all registered tools. + +# Returns +- `Vector{agentTool}`: Copy of the registry +""" +function get_tools()::Vector{agentTool} + return deepcopy(_registry) +end + +""" +List all registered tool names and labels. + +# Returns +- `Vector{Tuple{String,String}}`: Pairs of (name, label) +""" +function list_tools()::Vector{Tuple{String,String}} + return [(t.name, t.label) for t in _registry] +end + +""" +Clear all registered tools from the global registry. +""" +function clear_tools()::Nothing + empty!(_registry) + println("[toolRegistry] Registry cleared") + return nothing +end + +end # module diff --git a/src/type.jl b/src/type.jl index 46f95bc..66d4cda 100644 --- a/src/type.jl +++ b/src/type.jl @@ -197,23 +197,67 @@ end """ A tool available to the agent. +Maps MCP server tool definitions to an executable Julia tool. + # Arguments -- `name::String`: Tool identifier -- `label::String`: Human-readable tool name -- `description::String`: What the tool does -- `parameters::TParameters`: Tool parameters schema (JSON schema) -- `execute::Function`: Tool execution function +- `name::String`: Tool identifier (from MCP `name`) +- `label::String`: Human-readable tool name (from MCP `title`) +- `description::String`: What the tool does (from MCP `description`) +- `inputSchema::Any`: Tool parameters schema (from MCP `inputSchema`, JSON Schema format) +- `execute::Function`: Tool execution function, signature: + `execute(toolCallId::String, args::Dict, signal::Union{Nothing,AbortSignal}, onPartialResult::Function)` - `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback - `parallelToolExecute::Bool`: Override: run tool calls sequentially or in parallel # Returns - A new `agentTool` instance + +# MCP Tool Example +``` +{ + "name": "get_weather", + "title": "Weather Lookup", + "description": "Fetch current weather and forecast for a given city.", + "inputSchema": { + "type": "object", + "properties": { + "city": { "type": "string", "description": "City and state/country" }, + "units": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius" } + }, + "required": ["city"] + } +} +``` + +# Example +```julia +tool = agentTool( + name="get_weather", + label="Weather Lookup", + description="Fetch current weather and forecast for a given city.", + inputSchema=Dict( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City name"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"]) + ), + "required" => ["city"] + ), + execute=(toolCallId, args, signal, onPartialResult) -> begin + city = args["city"] + return agentToolResult( + [textContent("Sunny, 22C in $(city)")], + Dict{Any,Any}(), nothing, false + ) + end +) +``` """ -struct agentTool{TParameters, TDetails} # A tool available to the agent +struct agentTool # A tool available to the agent name::String # Tool identifier label::String # Human-readable tool name description::String # What the tool does - parameters::TParameters # Tool parameters schema (JSON schema) + inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) execute::Function # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel -- 2.52.0 From c1ef544734828909463a0640dfa730b478399ac2 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 08:04:08 +0700 Subject: [PATCH 048/112] update --- docs/loadtools.md | 22 +- example/agent_chat_virtualCustomer.jl | 585 --------------------- example/config.json | 76 --- example/main.jl | 706 -------------------------- src/tools/get_weather.jl | 6 +- src/tools/registry.jl | 36 +- 6 files changed, 32 insertions(+), 1399 deletions(-) delete mode 100644 example/agent_chat_virtualCustomer.jl delete mode 100644 example/config.json delete mode 100644 example/main.jl diff --git a/docs/loadtools.md b/docs/loadtools.md index c222248..8dfe6be 100644 --- a/docs/loadtools.md +++ b/docs/loadtools.md @@ -4,9 +4,9 @@ Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory w ## How It Works -1. `src/tools/registry.jl` defines a `load_tools(dir::String)` function that scans a directory for `.jl` files -2. Each tool file must define a single function: `get_tool() :: agentTool` -3. `load_tools()` sorts files alphabetically, includes each one, calls `get_tool()`, and registers the result +1. `src/tools/registry.jl` defines a `loadTools(dir::String)` function that scans a directory for `.jl` files +2. Each tool file must define a single function: `getTool() :: agentTool` +3. `loadTools()` sorts files alphabetically, includes each one, calls `getTool()`, and registers the result 4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent` ## Directory Structure @@ -26,11 +26,11 @@ src/ ## Creating a Tool -Each `.jl` file in `src/tools/` must define `get_tool()` returning an `agentTool`: +Each `.jl` file in `src/tools/` must define `getTool()` returning an `agentTool`: ```julia # src/tools/get_weather.jl -function get_tool() :: agentTool +function getTool() :: agentTool return agentTool( name = "get_weather", label = "Weather Lookup", @@ -65,7 +65,7 @@ using .YiemAgent using .YiemAgent: toolRegistry # Load all tool files from src/tools/ -tools = load_tools(joinpath(@__DIR__, "src", "tools")) +tools = YiemAgent.loadTools(joinpath(@__DIR__, "src", "tools")) # Create agent with loaded tools agent = yiemAgent( @@ -81,11 +81,11 @@ agent = yiemAgent( | Function | Description | |----------|-------------| -| `load_tools(dir::String)` | Scan directory and load all `.jl` tool files | -| `register_tool(tool::agentTool)` | Register a single tool into the global registry | -| `get_tools()` | Get deep copy of all registered tools | -| `list_tools()` | List all registered tools as `(name, label)` pairs | -| `clear_tools()` | Clear the global registry | +| `loadTools(dir::String)` | Scan directory and load all `.jl` tool files | +| `registerTool(tool::agentTool)` | Register a single tool into the global registry | +| `getTools()` | Get deep copy of all registered tools | +| `listTools()` | List all registered tools as `(name, label)` pairs | +| `clearTools()` | Clear the global registry | ## File Loading Order diff --git a/example/agent_chat_virtualCustomer.jl b/example/agent_chat_virtualCustomer.jl deleted file mode 100644 index b880599..0000000 --- a/example/agent_chat_virtualCustomer.jl +++ /dev/null @@ -1,585 +0,0 @@ -using Revise -using JSON, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures -using YiemAgent, GeneralUtils -using Base.Threads - -# ---------------------------------------------- 100 --------------------------------------------- # - - - -# load config -config = JSON.parsefile("/appfolder/app/dev/YiemAgent/test/config.json") -# config = copy(JSON.parsefile("../mountvolume/config.json")) - - -function executeSQL(sql::T) where {T<:AbstractString} - host = config[:externalservice][:wineDB][:host] - port = config[:externalservice][:wineDB][:port] - dbname = config[:externalservice][:wineDB][:dbname] - user = config[:externalservice][:wineDB][:user] - password = config[:externalservice][:wineDB][:password] - DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result -end - -function executeSQLVectorDB(sql) - host = config[:externalservice][:SQLVectorDB][:host] - port = config[:externalservice][:SQLVectorDB][:port] - dbname = config[:externalservice][:SQLVectorDB][:dbname] - user = config[:externalservice][:SQLVectorDB][:user] - password = config[:externalservice][:SQLVectorDB][:password] - DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result -end - -function text2textInstructLLM(prompt::String; maxattempt::Integer=10, modelsize::String="medium", - senderId=GeneralUtils.uuid4snakecase(), timeout=90, - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.5, - ) - ) - msgMeta = GeneralUtils.generate_msgMeta( - config[:externalservice][:loadbalancer][:mqtttopic]; - msgPurpose="inference", - senderName="yiemagent", - senderId=senderId, - receiverName="text2textinstruct_$modelsize", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :text => prompt, - :kwargs => llmkwargs - ) - ) - - response = nothing - for attempts in 1:maxattempt - _response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; responsetimeout=timeout, responsemaxattempt=maxattempt) - payload = _response[:response] - if _response[:success] && payload[:text] !== nothing - response = _response[:response][:text] - break - else - println("\n attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(outgoingMsg) - println(" attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - sleep(3) - end - end - - return response -end - -# get text embedding from a LLM service -function getEmbedding(text::T) where {T<:AbstractString} - msgMeta = GeneralUtils.generate_msgMeta( - config[:externalservice][:loadbalancer][:mqtttopic]; - msgPurpose="embedding", - senderName="yiemagent", - senderId=sessionId, - receiverName="textembedding", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :text => [text] # must be a vector of string - ) - ) - - response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; responsetimeout=120, responsemaxattempt=3) - embedding = response[:response][:embeddings] - return embedding -end - -function findSimilarTextFromVectorDB(text::T1, tablename::T2, embeddingColumnName::T3, - vectorDB::Function; limit::Integer=1 - )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} - # get embedding from LLM service - embedding = getEmbedding(text)[1] - # check whether there is close enough vector already store in vectorDB. if no, add, else skip - sql = """ - SELECT *, $embeddingColumnName <-> '$embedding' as distance - FROM $tablename - ORDER BY distance LIMIT $limit; - """ - response = vectorDB(sql) - df = DataFrame(response) - return df -end - -function similarSQLVectorDB(query; maxdistance::Integer=100) - tablename = "sqlllm_decision_repository" - # get embedding of the query - df = findSimilarTextFromVectorDB(query, tablename, - "function_input_embedding", executeSQLVectorDB) - # println(df[1, [:id, :function_output]]) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - # distance = 100 # CHANGE this is for testing only - if row != 0 && distance < maxdistance - # if there is usable SQL, return it. - output_b64 = df[1, :function_output_base64] # pick the closest match - output_str = String(base64decode(output_b64)) - rowid = df[1, :id] - println("\n~~~ found similar sql. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (dict=output_str, distance=distance) - else - println("\n~~~ similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (dict=nothing, distance=nothing) - end -end - -function insertSQLVectorDB(query::T1, SQL::T2; maxdistance::Integer=3) where {T1<:AbstractString, T2<:AbstractString} - tablename = "sqlllm_decision_repository" - # get embedding of the query - # query = state[:thoughtHistory][:question] - df = findSimilarTextFromVectorDB(query, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - query_embedding = getEmbedding(query)[1] - query = replace(query, "'" => "") - sql_base64 = base64encode(SQL) - sql_ = replace(SQL, "'" => "") - - sql = """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); - """ - # println("\n~~~ added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(sql) - _ = executeSQLVectorDB(sql) - end -end - - -function similarSommelierDecision(recentevents::T1; maxdistance::Integer=3 - )::Union{AbstractDict, Nothing} where {T1<:AbstractString} - tablename = "sommelier_decision_repository" - # find similar - println("\n~~~ search vectorDB for this: $recentevents ", @__FILE__, " ", @__LINE__) - df = findSimilarTextFromVectorDB(recentevents, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable decision, return it. - rowid = df[1, :id] - println("\n~~~ found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) - output_b64 = df[1, :function_output_base64] # pick the closest match - _output_str = String(base64decode(output_b64)) - output = copy(JSON.parsefile(_output_str)) - return output - else - println("\n~~~ similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) - return nothing - end -end - - -function insertSommelierDecision(recentevents::T1, decision::T2; maxdistance::Integer=5 - ) where {T1<:AbstractString, T2<:AbstractDict} - tablename = "sommelier_decision_repository" - # find similar - df = findSimilarTextFromVectorDB(recentevents, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - recentevents_embedding = getEmbedding(recentevents)[1] - recentevents = replace(recentevents, "'" => "") - decision_json = JSON.json(decision) - decision_base64 = base64encode(decision_json) - decision = replace(decision_json, "'" => "") - - sql = """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); - """ - println("\n~~~ added new decision to vectorDB ", @__FILE__, " ", @__LINE__) - println(sql) - _ = executeSQLVectorDB(sql) - else - println("~~~ similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) - end -end - - -sessionId = GeneralUtils.uuid4snakecase() - -externalFunction = ( - getEmbedding=getEmbedding, - text2textInstructLLM=text2textInstructLLM, - executeSQL=executeSQL, - similarSQLVectorDB=similarSQLVectorDB, - insertSQLVectorDB=insertSQLVectorDB, - similarSommelierDecision=similarSommelierDecision, - insertSommelierDecision=insertSommelierDecision, - ) - - -# s = "full-bodied red wine, budget 1500 USD" -# r = YiemAgent.extractWineAttributes_1(agent, s) -# println(r) - - -# --------------------------- generating scenario and customer profile --------------------------- # - -function rolegenerator() - rolegenerator_systemmsg = - """ - Your role: - - You are a helpful assistant - Your mission: - - Create one random role of a potential customer of an internet wine store. - You must follow the following guidelines: - - the user only need the role, do not add your own words. - - the role should be detailed and realistic. - You should then respond to the user with: - Name: a name of the potential customer - Situation: a situation that the potential customer may be facing - Mission: a mission of the potential customer - Profile: a profile of the potential customer, including their age, gender, occupation, and other relevant information - You should only respond in format as described below: - Name: ... - Situation: ... - Mission: ... - Profile: ... - Additional_information: ... - - Here are some examples: - Name: Jimmy - Situation: - - Your relationship with your boss is not that good. You need to improve your relationship with your boss. - - Your boss's wedding anniversary is coming up. - - You are at a wine store and start talking with the store's sommelier. - Mission: - - Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list. - Profile: - - You are a young professional in a big company. - - You are avid party goer - - You like beer. - - You know nothing about wine. - - You have a budget of 1500usd. - Additional_information: - - your boss like spicy food. - - your boss is a middle-aged man. - - your boss likes Australian wine. - - Name: Kate - Situation: - - Your husband asked you to get him a bottle of wine. He will gift the wine to his business client while dining at a German restaurant. - - Your husband is a business client and he will gift the wine to his business - - You are at a wine store and start talking with the store's sommelier. - Mission: - - Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list. - Profile: - - You are a CEO in a startup company. - - You are a nerd - - You don't like alcohol. - - You have a budget of 150usd. - - You don't care about organic, sulfite, gluten-free, or sustainability certified wines - Additional_information: - - your husband like spicy food. - - your husband is a middle-aged man. - - Name: John - Situation: - - A local newspaper club wants to have a scoop about wine with local food in the U.S. - - You are at a wine store and start talking with the store's sommelier. - Mission: - - Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list. - Profile: - - I'm a young guy. - - I prefer to express my ideas in a succinct and clear manner. - Additional_information: - - N/A - - Name: Jane - Situation: - - You have catering a dinner party with French cuisine. - - You want to serve wine with your guests. - - You are at a wine store and start talking with the store's sommelier. - Mission: - - Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list. - Profile: - - You are a young French restaurant owner. - - You like dry, full-bodied red wine with high tannin - - You don't care about organic, sulfite, gluten-free, or sustainability certified wines. - - You have a budget of 200 usd. - Additional_information: - - N/A - - Let's begin! - """ - - header = ["Name:", "Situation:", "Mission:", "Profile:", "Additional_information:"] - dictkey = ["name", "situation", "mission", "profile", "additional_information"] - errornote = "N/A" - - for attempt in 1:10 - _prompt = - [ - Dict(:name => "system", :text => rolegenerator_systemmsg), - ] - prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3") - - response = text2textInstructLLM(prompt) # generated role - response = GeneralUtils.deFormatLLMtext(response, "qwen3") - think, response = GeneralUtils.extractthink(response) - - # check whether response has all header - detected_kw = GeneralUtils.detect_keyword(header, response) - kwvalue = [i for i in values(detected_kw)] - zeroind = findall(x -> x == 0, kwvalue) - missingkeys = [header[i] for i in zeroind] - if 0 ∈ values(detected_kw) - errornote = "$missingkeys are missing from your previous response" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - responsedict[:id] = GeneralUtils.uuid4snakecase() - - responsedict[:systemmsg] = - """ - You are role playing as a CUSTOMER of a wine store and you are currently talking with a sommelier of a wine store. - Your profile is as follows: - Situation: $(responsedict[:situation]) - Mission: $(responsedict[:mission]) - Profile: $(responsedict[:profile]) - Additional_information: $(responsedict[:additional_information]) - - You should follow the following guidelines: - - Focus on the lastest conversation - - Your like to be short and concise - - If you don't know an answer to sommelier's question, you should say: I don't know. - - If you think the store can't provide what you seek, you can leave. - - You should then respond to the user with: - Dialogue: what you want to say to the user - Role: Verify that the dialogue is intended for the customer of a wine store. Can be "yes" or "no" - You should only respond in format as described below: - Dialogue: ... - Role: ... - - Let's begin! - """ - - println("\nrolegenerator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - println(responsedict) - return responsedict - end - error("ERROR rolegenerator() failed to generate customer role: ", @__FILE__, ":", @__LINE__, " $(Dates.now())") -end - - -# Define the external functions for the customer agent in named tuple format -customer_externalFunction = ( - text2textInstructLLM=text2textInstructLLM, - ) - - - - -function main() - agent = YiemAgent.sommelier( - externalFunction; - name="Jane", - id=sessionId, # agent instance id - retailername="Yiem", - llmFormatName="qwen3" - ) - - customerDict = rolegenerator() - customer = YiemAgent.virtualcustomer( - customer_externalFunction; - systemmsg=customerDict[:systemmsg], - name=customerDict[:name], - id=sessionId, # agent instance id - llmFormatName="qwen3" - ) - - # customer_chat = "hello" - - # YiemAgent.addNewMessage(customer, "assistant", customer_chat) - # # add user activity to events memory - # push!(customer.memory[:events], - # YiemAgent.eventdict(; - # event_description="the assistant talks to the user.", - # timestamp=Dates.now(), - # subject="assistant", - # action_name="CHAT_BOX", - # action_input=customer_chat, - # ) - # ) - # println("\ncustomer respond:\n $customer_chat") - agent_response = YiemAgent.conversation(agent; maximumMsg=50) - println("\nagent respond:\n $agent_response") - while true - customer_chat = nothing - while customer_chat === nothing - customer_response = YiemAgent.conversation(customer, Dict(:text=> agent_response); - converPartnerName=agent.name, - maximumMsg=50) - customer_response = GeneralUtils.deFormatLLMtext(customer_response, customer.llmFormatName) - customer_chat = customer_response - - #[WORKING] check whether customer response the same before - end - - println("\ncustomer respond:\n $customer_chat") - - agent_response = YiemAgent.conversation(agent; - userinput=Dict(:text=> customer_chat), - maximumMsg=50) - println("\nagent respond:\n $agent_response") - - if haskey(agent.memory[:events][end], :thought) - lastAssistantAction = agent.memory[:events][end][:thought][:action_name] - if lastAssistantAction == "END_CONVER_GUIDELINE" # store thoughtDict - - # save a.memory[:shortmem][:decisionlog] to disk using JSON - println("\nsaving agent.memory[:shortmem][:decisionlog] to disk") - date = "$(Dates.now())" - date = replace(date, ':'=>'.') - filename = "agent_decision_log_$(date)_$(agent.id).json" - filepath = "/appfolder/mountvolume/appdata/log/$filename" - open(filepath, "w") do io - JSON.pretty(io, agent.memory[:shortmem][:decisionlog]) - end - - # check how many file in /appfolder/mountvolume/appdata/log/ folder now - logfilesnumber = length(readdir("/appfolder/mountvolume/appdata/log/")) - println("\nCaching conversation process done. Total $logfilesnumber files in /appfolder/mountvolume/appdata/log/ folder now.\n") - break - end - end - end -end - -for i in 1:100 - main() - println("\n Round $i/100 done.") -end - -println("done") - -# prompt = -# """ -# <|im_start|>system -# You are a role playing agent acting as: -# Name: Emily -# Situation: - Emily is planning her upcoming birthday party and wants to make it extra special. She has invited close friends and family, and she's looking for a unique wine that will impress them. -# Mission: - Emily needs to find a rare and high-quality wine that matches the theme of her party, which is a mix of classic and modern flavors. She also wants to ensure that the wine is not too expensive so that it won't break her budget. -# Profile: - Emily is in her late 20s, works as a marketing executive for a tech company, and has a passion for trying new things. She's organized and detail-oriented but can be spontaneous when it comes to planning events. -# Additional_information: - Emily loves experimenting with different types of food and wine pairings. - -# Your are currently talking with a sommelier. - -# You should follow the following guidelines: -# - Focus on the lastest conversation -# - If you satisfy with the sommelier's recommendation for bottle of wine(s), you should say: Thanks for you help. I will buy the wine you recommended. -# - If you don't satisfy with the sommelier's questions or can't get a good wine recommendation, you can continue the conversation. - -# Let's begin! - -# <|im_end|> -# <|im_start|>Jane -# Hello! Welcome to Yiem's Wine Store. I'm Jane, your friendly sommelier. How can I assist you today? What type of wine are you in the mood for, and is there a special occasion or event on your mind? -# <|im_end|> -# <|im_start|>Emily -# Hi Jane! Thank you so much for welcoming me. For my birthday party, I'm looking for something that combines classic and modern flavors. It's a mix of guests who enjoy both traditional tastes and more contemporary ones. Also, I want to make sure it won't break the bank. Any suggestions? -# <|im_end|> -# <|im_start|>Jane -# Thank you for sharing your preferences, Jane! To better assist you, could you please let me know if there are any specific characteristics of wine you're looking for, such as tannin, sweetness, intensity, or acidity? Additionally, do you have any food items in mind that this wine should pair well with? -# <|im_end|> -# <|im_start|>Emily -# """ - -# llmkwargs=Dict( -# :num_ctx => 32768, -# :temperature => 0.3, -# ) -# r = text2textInstructLLM(prompt, llmkwargs=llmkwargs) -# println(r) -# println(555) - -# response = YiemAgent.conversation(agent, Dict(:text=> "I want to get a French red wine under 100.")) - - -# while true -# println("your respond: ") -# user_answer = readline() -# response = YiemAgent.conversation(agent, Dict(:text=> user_answer)) -# println("\n$response") -# end - - - - - - - - - - - - -# """ -# Hello - -# I would like to get a bottle of wine for my boss but I don't know much about wine. Can you help me? - -# well actually, my boss is going to offer the wine to his client as a gift in a business meeting. All I know is his client like spicy food and French wine. I have a budget about 1000. - -# """ - -# input = "French wine, bordeaux, under USD100, pairs with spicy food" -# r = YiemAgent.extractWineAttributes_1(a, input) - -# inventory_order = "French Syrah, Viognier, full bodied, under 100" -# r = YiemAgent.extractWineAttributes_2(a, inventory_order) -# pprintln(r) - - -# cron job -# @reboot sleep 50 && nvidia-smi -pm 1 -# @reboot sleep 51 && nvidia-smi -i 0 -pl 150 -# @reboot sleep 52 && nvidia-smi -i 1 -pl 150 -# @reboot sleep 53 && nvidia-smi -i 2 -pl 150 -# @reboot sleep 54 && nvidia-smi -i 3 -pl 150 - -# @reboot sleep 55 && julia -t 2 /home/ton/work/restartContainer/main.jl - -# using GeneralUtils -# msgMeta = GeneralUtils.generate_msgMeta( -# "/tonpc_containerServices", -# senderName= "somename", -# senderId= "1230", -# mqttBrokerAddress= "mqtt.yiem.cc", -# mqttBrokerPort= 1883, -# ) -# outgoingMsg = Dict( -# :msgMeta=> msgMeta, -# :payload=> "docker container restart playground-app", -# ) -# GeneralUtils.sendMqttMsg(outgoingMsg) - diff --git a/example/config.json b/example/config.json deleted file mode 100644 index 13ca00f..0000000 --- a/example/config.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "mqttServerInfo": { - "description": "mqtt server info", - "port": 1883, - "broker": "mqtt.yiem.cc" - }, - "testingOrProduction": { - "value": "testing", - "description": "agent status, couldbe testing or production" - }, - "agentid": { - "value": "2b74b87a-5413-4fe2-a4d3-405891051680", - "description": "a unique id for this agent" - }, - "agentCentralConfigTopic": { - "mqtttopic": "/yiem_branch_1/agent/sommelier/backend/config/api/v1.1", - "description": "a central agent server's topic to get this agent config" - }, - "servicetopic": { - "mqtttopic": [ - "/yiem/hq/agent/sommelier/backend/prompt/api_v1/testing" - ], - "description": "a topic this agent are waiting for service request" - }, - "role": { - "value": "sommelier", - "description": "agent role" - }, - "organization": { - "value": "yiem_branch_1", - "description": "organization name" - }, - "externalservice": { - "loadbalancer": { - "mqtttopic": "/loadbalancer/requestingservice", - "description": "text to text service with instruct LLM" - }, - "text2textinstruct": { - "mqtttopic": "/loadbalancer/requestingservice", - "description": "text to text service with instruct LLM", - "llminfo": { - "name": "llama3instruct" - } - }, - "virtualWineCustomer_1": { - "mqtttopic": "/virtualenvironment/winecustomer", - "description": "text to text service with instruct LLM that act as wine customer", - "llminfo": { - "name": "llama3instruct" - } - }, - "text2textchat": { - "mqtttopic": "/loadbalancer/requestingservice", - "description": "text to text service with instruct LLM", - "llminfo": { - "name": "llama3instruct" - } - }, - "wineDB" : { - "description": "A wine database connection info for LibPQ client", - "host": "192.168.88.12", - "port": 10201, - "dbname": "wineDB", - "user": "yiemtechnologies", - "password": "yiemtechnologies@Postgres_0.0" - }, - "SQLVectorDB" : { - "description": "A wine database connection info for LibPQ client", - "host": "192.168.88.12", - "port": 10203, - "dbname": "SQLVectorDB", - "user": "yiemtechnologies", - "password": "yiemtechnologies@Postgres_0.0" - } - } -} \ No newline at end of file diff --git a/example/main.jl b/example/main.jl deleted file mode 100644 index c28785a..0000000 --- a/example/main.jl +++ /dev/null @@ -1,706 +0,0 @@ -using JSON, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures -using YiemAgent, GeneralUtils -using Base.Threads - -# ---------------------------------------------- 100 --------------------------------------------- # - - -""" Expected incomming MQTT message format for this service: - { - "msgMeta": { - "msgPurpose": "updateStatus", - "requestresponse": "request", - "timestamp": "2024-03-29T05:8:48.362", - "replyToMsgId": null, - "receiverId": null, - "getpost": "get", - "msgId": "e5c09bd8-7100-4e4e-bb43-05bee589a22c", - "acknowledgestatus": null, - "sendTopic": "/agent/wine/backend/chat/api/v1/prompt", - "receiverName": "agent-wine-backend", - "replyTopic": "/agent/wine/frontend/chat/api/v1/txt/receive", - "senderName": "agent-wine-frontend-chat", - "senderId": "0938a757-e0ee-40a9-8355-5e24906a87cd" - }, - "payload" : { - "text": "hello" - } - - } -""" - - - - - -# load config -config = copy(JSON.parsefile("../mountvolume/config/config.json")) - -""" Instantiate an agent. One need to specify startmessage and one of gpu location info, - Mqtt or Rest. start message must be comply with GeneralUtils's message format - - Arguments\n - ----- - channel::Channel - communication channel - sessionId::String - sesstion ID of the agent - agentName::String - Name of the agent - mqttBroker::String - mqtt broker e.g. "tcp://127.0.0.1:1883" - agentConfigTopic::String - main communication topic for an agent to ask for config - timeout::Int64 - inactivity timeout in minutes. If timeout is reached, an agent will be terminated. - - Return\n - ----- - a task represent an agent - - Example\n - ----- - ```jldoctest - julia> using YiemAgent, GeneralUtils - julia> msg = GeneralUtils.generate_msgMeta("/agent") - julia> incoming_msg = msg # assuming 1st msg was sent from other app - julia> agentConfigTopic = "/agent/wine/backend/config" - julia> task = runAgentInstance(incoming_msg, mqttBroker, agentConfigTopic, 60) - ``` - - TODO\n - ----- - [] update docstringLAMA_CONTEXT_LENGTH=40960 since the default size is 2048 as you can see in your debug log: - [] change how to get result of YiemAgent from let YiemAgent send msg directly to frontend, - to - response = YiemAgent.conversation() - then send response to frontend - - Signature\n - ----- -""" -function runAgentInstance( - receiveUserMsgChannel::Channel, - outputchannel::Channel, - sessionId::String, - config::Dict, - timeout::Int64, - ) - - function executeSQL(sql::T) where {T<:AbstractString} - host = config[:externalservice][:wineDB][:host] - port = config[:externalservice][:wineDB][:port] - dbname = config[:externalservice][:wineDB][:dbname] - user = config[:externalservice][:wineDB][:user] - password = config[:externalservice][:wineDB][:password] - DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result - end - - function executeSQLVectorDB(sql) - host = config[:externalservice][:SQLVectorDB][:host] - port = config[:externalservice][:SQLVectorDB][:port] - dbname = config[:externalservice][:SQLVectorDB][:dbname] - user = config[:externalservice][:SQLVectorDB][:user] - password = config[:externalservice][:SQLVectorDB][:password] - DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result - end - - function text2textInstructLLM(prompt::String; maxattempt::Integer=3, modelsize::String="medium", - senderId=GeneralUtils.uuid4snakecase(), timeout=180, - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.5, - )) - msgMeta = GeneralUtils.generate_msgMeta( - config[:externalservice][:loadbalancer][:mqtttopic]; - msgPurpose="inference", - senderName="yiemagent", - senderId=senderId, - receiverName="text2textinstruct_$modelsize", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :text => prompt, - :kwargs => llmkwargs - ) - ) - - response = nothing - for attempts in 1:maxattempt - _response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=timeout, maxattempt=maxattempt) - payload = _response[:response] - if _response[:success] && payload[:text] !== nothing - response = _response[:response][:text] - break - else - println("\n attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(outgoingMsg) - println(" attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - sleep(3) - end - end - - return response - end - - # get text embedding from a LLM service - function getEmbedding(text::T) where {T<:AbstractString} - msgMeta = GeneralUtils.generate_msgMeta( - config[:externalservice][:loadbalancer][:mqtttopic]; - msgPurpose="embedding", - senderName="yiemagent", - senderId=sessionId, - receiverName="textembedding", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :text => [text] # must be a vector of string - ) - ) - - response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120, maxattempt=3) - embedding = response[:response][:embeddings] - return embedding - end - - function findSimilarTextFromVectorDB(text::T1, tablename::T2, embeddingColumnName::T3, - vectorDB::Function; limit::Integer=1 - )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} - # get embedding from LLM service - embedding = getEmbedding(text)[1] - # check whether there is close enough vector already store in vectorDB. if no, add, else skip - sql = """ - SELECT *, $embeddingColumnName <-> '$embedding' as distance - FROM $tablename - ORDER BY distance LIMIT $limit; - """ - response = vectorDB(sql) - df = DataFrame(response) - return df - end - - function similarSQLVectorDB(query; maxdistance::Integer=100) - tablename = "sqlllm_decision_repository" - # get embedding of the query - df = findSimilarTextFromVectorDB(query, tablename, - "function_input_embedding", executeSQLVectorDB) - # println(df[1, [:id, :function_output]]) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - # distance = 100 # CHANGE this is for testing only - if row != 0 && distance < maxdistance - # if there is usable SQL, return it. - output_b64 = df[1, :function_output_base64] # pick the closest match - output_str = String(base64decode(output_b64)) - rowid = df[1, :id] - println("\n~~~ found similar sql. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (dict=output_str, distance=distance) - else - println("\n~~~ similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (dict=nothing, distance=nothing) - end - end - - function insertSQLVectorDB(query::T1, SQL::T2; maxdistance::Integer=3) where {T1<:AbstractString, T2<:AbstractString} - tablename = "sqlllm_decision_repository" - # get embedding of the query - # query = state[:thoughtHistory][:question] - df = findSimilarTextFromVectorDB(query, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - query_embedding = getEmbedding(query)[1] - query = replace(query, "'" => "") - sql_base64 = base64encode(SQL) - sql_ = replace(SQL, "'" => "") - - sql = """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); - """ - # println("\n~~~ added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(sql) - _ = executeSQLVectorDB(sql) - end - end - - - function similarSommelierDecision(recentevents::T1; maxdistance::Integer=3 - )::Union{AbstractDict, Nothing} where {T1<:AbstractString} - tablename = "sommelier_decision_repository" - # find similar - println("\n~~~ search vectorDB for this: $recentevents ", @__FILE__, " ", @__LINE__) - df = findSimilarTextFromVectorDB(recentevents, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable decision, return it. - rowid = df[1, :id] - println("\n~~~ found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) - output_b64 = df[1, :function_output_base64] # pick the closest match - _output_str = String(base64decode(output_b64)) - output = copy(JSON.parsefile(_output_str)) - return output - else - println("\n~~~ similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) - return nothing - end - end - - - function insertSommelierDecision(recentevents::T1, decision::T2; maxdistance::Integer=5 - ) where {T1<:AbstractString, T2<:AbstractDict} - tablename = "sommelier_decision_repository" - # find similar - df = findSimilarTextFromVectorDB(recentevents, tablename, - "function_input_embedding", executeSQLVectorDB) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - recentevents_embedding = getEmbedding(recentevents)[1] - recentevents = replace(recentevents, "'" => "") - decision_json = JSON.json(decision) - decision_base64 = base64encode(decision_json) - decision = replace(decision_json, "'" => "") - - sql = - """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); - """ - println("\n~~~ added new decision to vectorDB ", @__FILE__, " ", @__LINE__) - println(sql) - _ = executeSQLVectorDB(sql) - else - println("~~~ similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) - end - end - - # keepaliveChannel_2::Channel{Dict} = Channel{Dict}(8) - latestUserMsgTimeStamp::DateTime = Dates.now() - - externalFunction = ( - getEmbedding=getEmbedding, - text2textInstructLLM=text2textInstructLLM, - executeSQL=executeSQL, - similarSQLVectorDB=similarSQLVectorDB, - insertSQLVectorDB=insertSQLVectorDB, - similarSommelierDecision=similarSommelierDecision, - insertSommelierDecision=insertSommelierDecision, - ) - - agent = YiemAgent.sommelier( - externalFunction; - name="Jane", - id=sessionId, # agent instance id - retailername="Yiem", - llmFormatName="qwen3" - ) - - # user chat loop - while true - # check for new user message - if isready(receiveUserMsgChannel) - incomingMsg = take!(receiveUserMsgChannel) - incoming_msgMeta = incomingMsg[:msgMeta] - incomingPayload = incomingMsg[:payload] - latestUserMsgTimeStamp = Dates.now() - - # make sure the message has :text key because YiemAgent use this key for incoming user msg - if haskey(incomingPayload, :text) - # skip, msg already has correct key name - elseif haskey(incomingPayload, :txt) - # change key name to text - incomingPayload[:text] = incomingPayload[:txt] - else - error("\n no :txt or :text key in the message.") - end - - # reset agent - if occursin("newtopic", incomingPayload[:text]) || - occursin("Newtopic", incomingPayload[:text]) || - occursin("New topic", incomingPayload[:text]) || - occursin("new topic", incomingPayload[:text]) - # YiemAgent.clearhistory(agent) - - agent = YiemAgent.sommelier( - externalFunction; - name="Janie", - id=sessionId, # agent instance id - retailername="Yiem", - ) - - # sending msg back to sender i.e. LINE - msgMeta = GeneralUtils.generate_msgMeta( - incomingMsg[:msgMeta][:replyTopic]; - senderName="wine_assistant_backend", - senderId=sessionId, - replyToMsgId=incomingMsg[:msgMeta][:msgId], - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :alias => agent.name, # will be shown in frontend as agent name - :text => "Okay. What shall we talk about?" - ) - ) - _ = GeneralUtils.sendMqttMsg(outgoingMsg) - println("--> outgoingMsg ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(outgoingMsg) - else - usermsg = incomingPayload - - if incoming_msgMeta[:msgPurpose] == "initialize" - println("\n-- Initializing... ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - # send prompt - result = YiemAgent.conversation(agent; - userinput=usermsg, - maximumMsg=50) - # Ken's bot use [br] for newline character '\n' - # result = replace(result, '\n'=>"[br]") - - if incoming_msgMeta[:msgPurpose] == "initialize" - println("\n-- Initialized. Ready! waiting for request at:\n$(config[:servicetopic][:mqtttopic]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - msgMeta = GeneralUtils.generate_msgMeta( - incomingMsg[:msgMeta][:replyTopic]; - senderName="wine_assistant_backend", - senderId=string(uuid4()), - replyToMsgId=incomingMsg[:msgMeta][:msgId], - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( - :alias => agent.name, # will be shown in frontend as agent name - :text => result - ) - ) - _ = GeneralUtils.sendMqttMsg(outgoingMsg) - println("\n--> outgoingMsg ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(outgoingMsg) - - - # jpg_as_juliaStr = nothing - # prompt = nothing - - # if haskey(payload, "img") - # url_or_base64 = payload["img"] - - # if startswith(url_or_base64, "http") - # # img in http - # julia_rgb_img, cv2_bgr_img = ImageUtils.url_to_cv2_image(url_or_base64) - # _, buffer = cv2.imencode(".jpg", cv2_bgr_img) - # jpg_as_pyStr = base64.b64encode(buffer).decode("utf-8") - # jpg_as_juliaStr = pyconvert(String, jpg_as_pyStr) - # else - # # img in base64 - # cv2_bgr_img = payload["img"] - # jpg_as_juliaStr = pyconvert(String, jpg_as_pyStr) - # end - # end - - end - else - # println("\n no msg") - end - - if haskey(agent.memory[:events][end], :thought) - lastAssistantAction = agent.memory[:events][end][:thought][:action_name] - if lastAssistantAction == "END_CONVER_GUIDELINE" # store thoughtDict - - # save a.memory[:shortmem][:decisionlog] to disk using JSON - println("\nsaving agent.memory[:shortmem][:decisionlog] to disk") - filename = "agent_decision_log_$(Dates.now())_$(agent.id).json" - filepath = "/appfolder/app/log/$filename" - open(filepath, "w") do io - JSON.pretty(io, agent.memory[:shortmem][:decisionlog]) - end - - # for (i, event) in enumerate(agent.memory[:events]) - # if event[:subject] == "assistant" - # # create timeline of the last 3 conversation except the last one. - # # The former will be used as caching key and the latter will be the caching target - # # in vector database - # all_recapkeys = keys(agent.memory[:recap]) #[TESTING] recap as caching - # all_recapkeys_vec = [r for r in all_recapkeys] # convert to a vector - - # # select from 1 to 2nd-to-lase event (i.e. excluding the latest which is assistant's response) - # _recapkeys_vec = all_recapkeys_vec[1:i-1] - - # # select only previous 3 recaps - # recapkeys_vec = - # if length(_recapkeys_vec) <= 3 # 1st message is a user's hello msg - # _recapkeys_vec # choose all - # else - # _recapkeys_vec[end-2:end] - # end - # #[PENDING] if there is specific data such as number, donot store in database - # tempmem = DataStructures.OrderedDict() - # for k in recapkeys_vec - # tempmem[k] = agent.memory[:recap][k] - # end - - # recap = GeneralUtils.dictToString_noKey(tempmem) - # thoughtDict = agent.memory[:events][i][:thought] # latest assistant thoughtDict - # insertSommelierDecision(recap, thoughtDict) - # else - # # skip - # end - # end - println("\nCaching conversation process done") - break - end - end - - # self terminate if too long inactivity - timediff = GeneralUtils.timedifference(latestUserMsgTimeStamp, Dates.now(), "minutes") - if timediff > timeout - - result = Dict(:exitreason => "timeout", :timestamp => Dates.now()) - put!(outputchannel, result) - println("Agent ID $(agent.id) timeout has been reached $timediff/$timeout minutes Send delete session msg ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - - # send "delete session" message to inform the main loop that this session can be deleted - sendto = - if typeof(config[:servicetopic][:mqtttopic]) <: Array - config[:servicetopic][:mqtttopic][1] - else - config[:servicetopic][:mqtttopic] - end - - msgMeta = GeneralUtils.generate_msgMeta( - sendto; - senderName="session", - senderId=sessionId, - msgPurpose="delete session", - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => nothing - ) - _ = GeneralUtils.sendMqttMsg(outgoingMsg) - - try disconnect(agent.mqttClient) catch end - break - end - sleep(1) # allowing on_msg_2, asyncmove above and other process to run - end -end - -sessionDict = Dict{String,Any}() -incomingMsgChannel = (ch1=Channel(8),) # store msg that coming into servicetopic -# incommingInternalMsg = [] # st ore msg that coming into servicetopic internal management -keepaliveChannel::Channel{Dict} = Channel{Dict}(8) - -# Define the callback for receiving messages. -function onMsgCallback_1(topic, payload) - jobj = JSON.parsefile(String(payload)) - incomingMqttMsg = copy(jobj) # convert json object into julia dictionary recursively - - if occursin("keepalive", topic) - put!(keepaliveChannel, incomingMqttMsg) - else - put!(incomingMsgChannel[:ch1], incomingMqttMsg) - end -end - -mqttInstance = GeneralUtils.mqttClientInstance_v2( - config[:mqttServerInfo][:broker], - config[:servicetopic][:mqtttopic], - incomingMsgChannel, - keepaliveChannel, - onMsgCallback_1 -) - -# ------------------------------------------------------------------------------------------------ # -# this service main loop # -# ------------------------------------------------------------------------------------------------ # - -function main() - sessiontimeout = 1 * 1 * 60 # timeout in minute for each instance (day * hour * minute) - initializing = false - while true - # check if mqtt connection is still up - _ = GeneralUtils.checkMqttConnection!(mqttInstance; keepaliveCheckInterval=30) - - # initialize session 0 - if initializing == false # send init msg - sendto = - if typeof(config[:servicetopic][:mqtttopic]) <: Array - config[:servicetopic][:mqtttopic][1] - else - config[:servicetopic][:mqtttopic] - end - - msgMeta = GeneralUtils.generate_msgMeta( - sendto; - msgPurpose="initialize", - senderName="initializer", - senderId="0", - msgId= "initMsg", - replyTopic=sendto, - mqttBrokerAddress=config[:mqttServerInfo][:broker], - mqttBrokerPort=config[:mqttServerInfo][:port], - ) - - outgoingMsg = Dict( - :msgMeta => msgMeta, - :payload => Dict( # will be shown in frontend as agent name - :text => "Do you have full-bodied red wines under 100 USD. I don't have any other preferences." - ) - ) - _ = GeneralUtils.sendMqttMsg(outgoingMsg) - initializing = true - println("\n--> Initializing msg sent ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - # check for new message - if !isempty(incomingMsgChannel[:ch1]) - msg = popfirst!(incomingMsgChannel[:ch1]) - println("\n<-- incomingMsg ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(msg) - - # @spawn new runAgentInstance and store it in sessionDict - # use agent's frontend id because 1 backend agent per 1 frontend session - sessionId = msg[:msgMeta][:senderId] - sessionId = replace(sessionId, "-" => "_") # julia can't use "-" in a dict key - - # check for delete session msg - if msg[:msgMeta][:msgPurpose] == "delete session" - delete!(sessionDict, sessionId) - println("sessionId $(sessionId) has been terminated ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - - # no session yet, create new session - elseif sessionId ∉ keys(sessionDict) - inputch = Channel{Dict}(8) - outputch = Channel{Dict}(8) - - process = @spawn runAgentInstance(inputch, outputch, sessionId, config, sessiontimeout) - # process = runAgentInstance(inputch, outputch, sessionId, config, sessiontimeout) #XXX use spawn version - - println("\ninstantiate agent success ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - - # call runAgentInstance() and store it in sessionDict to be able to check on it later - sessionDict[sessionId] = Dict( - :inputchannel => inputch, - :outputchannel => outputch, - :process => process, - ) - put!(sessionDict[sessionId][:inputchannel], msg) - # ongoing session - else - println("sessionId $(sessionId) existing session ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - put!(sessionDict[sessionId][:inputchannel], msg) - end - end - - # sleep is needed because MQTTClient use async. "while true" loop leave no - # chance for control to switch to on_msg() - sleep(1) - end -end - -main() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/tools/get_weather.jl b/src/tools/get_weather.jl index 808586a..9b23aef 100644 --- a/src/tools/get_weather.jl +++ b/src/tools/get_weather.jl @@ -10,7 +10,7 @@ Execute the get_weather tool. # Returns - `agentToolResult`: Result content with weather data """ -function execute_tool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) :: agentToolResult +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) :: agentToolResult city = get(args, "city", "") units = get(args, "units", "celsius") @@ -39,7 +39,7 @@ end """ Define and return the get_weather agentTool. """ -function get_tool() :: agentTool +function getTool() :: agentTool return agentTool( name = "get_weather", label = "Weather Lookup", @@ -52,7 +52,7 @@ function get_tool() :: agentTool ), "required" => ["city"] ), - execute = execute_tool, # reference the function defined above + execute = executeTool, # reference the function defined above prepareArguments = nothing, parallelToolExecute = false ) diff --git a/src/tools/registry.jl b/src/tools/registry.jl index de121fd..b4cb46a 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -1,25 +1,25 @@ module toolRegistry -export load_tools, register_tool, get_tools, list_tools, clear_tools +export loadTools, registerTool, getTools, listTools, clearTools using ..type -# Global registry — populated at runtime by load_tools() or register_tool() +# Global registry — populated at runtime by loadTools() or registerTool() const _registry = Vector{agentTool}() """ Load all tool modules from a directory. Scans `dir` for `.jl` files. Each file must define a function named -`get_tool() :: agentTool`. Files are sorted alphabetically so tool +`getTool() :: agentTool`. Files are sorted alphabetically so tool registration order is deterministic. # Tool file format -Each `.jl` file defines one function `get_tool()` that returns an `agentTool`: +Each `.jl` file defines one function `getTool()` that returns an `agentTool`: ```julia # src/tools/get_weather.jl -function get_tool() :: agentTool +function getTool() :: agentTool return agentTool( name = "get_weather", label = "Weather Lookup", @@ -52,9 +52,9 @@ end - `Vector{agentTool}`: All loaded tools # Errors -- Throws `ArgumentError` if a tool file does not define a `get_tool` function +- Throws `ArgumentError` if a tool file does not define a `getTool` function """ -function load_tools(dir::String)::Vector{agentTool} +function loadTools(dir::String)::Vector{agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end @@ -71,19 +71,19 @@ function load_tools(dir::String)::Vector{agentTool} # (agentTool, textContent, agentToolResult, etc. are all available) include(filepath) - # Validate that get_tool was defined (include() places it in current module scope) - if !isdefined(:get_tool) + # Validate that getTool was defined (include() places it in current module scope) + if !isdefined(:getTool) throw(ArgumentError( - "Tool file $(filepath) does not define a `get_tool()` function. " * - "Each tool file must define: function get_tool() :: agentTool ... end" + "Tool file $(filepath) does not define a `getTool()` function. " * + "Each tool file must define: function getTool() :: agentTool ... end" )) end - # Call get_tool() — it runs in current scope where types are visible - tool = get_tool() + # Call getTool() — it runs in current scope where types are visible + tool = getTool() if !(tool isa agentTool) throw(ArgumentError( - "get_tool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" + "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" )) end @@ -104,7 +104,7 @@ Register a single agentTool into the global registry. # Returns - `Vector{agentTool}`: Updated registry """ -function register_tool(tool::agentTool)::Vector{agentTool} +function registerTool(tool::agentTool)::Vector{agentTool} push!(_registry, tool) println("[toolRegistry] Registered tool: $(tool.name)") return _registry @@ -116,7 +116,7 @@ Get all registered tools. # Returns - `Vector{agentTool}`: Copy of the registry """ -function get_tools()::Vector{agentTool} +function getTools()::Vector{agentTool} return deepcopy(_registry) end @@ -126,14 +126,14 @@ List all registered tool names and labels. # Returns - `Vector{Tuple{String,String}}`: Pairs of (name, label) """ -function list_tools()::Vector{Tuple{String,String}} +function listTools()::Vector{Tuple{String,String}} return [(t.name, t.label) for t in _registry] end """ Clear all registered tools from the global registry. """ -function clear_tools()::Nothing +function clearTools()::Nothing empty!(_registry) println("[toolRegistry] Registry cleared") return nothing -- 2.52.0 From 04e75e61b8c7fd403d200d59a60abee3b9cf4823 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 08:51:07 +0700 Subject: [PATCH 049/112] update --- docs/loadtools.md | 10 ++--- src/tools/getTime.jl | 49 +++++++++++++++++++++ src/tools/{get_weather.jl => getWeather.jl} | 10 ++--- src/tools/registry.jl | 12 ++--- src/type.jl | 4 +- src_OLD/OLD_interface.jl | 4 +- 6 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 src/tools/getTime.jl rename src/tools/{get_weather.jl => getWeather.jl} (89%) diff --git a/docs/loadtools.md b/docs/loadtools.md index 8dfe6be..42f446c 100644 --- a/docs/loadtools.md +++ b/docs/loadtools.md @@ -5,7 +5,7 @@ Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory w ## How It Works 1. `src/tools/registry.jl` defines a `loadTools(dir::String)` function that scans a directory for `.jl` files -2. Each tool file must define a single function: `getTool() :: agentTool` +2. Each tool file must define a single function: `getTool()::agentTool` 3. `loadTools()` sorts files alphabetically, includes each one, calls `getTool()`, and registers the result 4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent` @@ -15,7 +15,7 @@ Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory w src/ ├── tools/ │ ├── registry.jl # Tool loader (do not edit) -│ ├── get_weather.jl # Your tool +│ ├── getWeather.jl # Your tool │ └── query_db.jl # Another tool ├── type.jl ├── utils.jl @@ -29,10 +29,10 @@ src/ Each `.jl` file in `src/tools/` must define `getTool()` returning an `agentTool`: ```julia -# src/tools/get_weather.jl -function getTool() :: agentTool +# src/tools/getWeather.jl +function getTool()::agentTool return agentTool( - name = "get_weather", + name = "getWeather", label = "Weather Lookup", description = "Fetch current weather and forecast for a given city.", inputSchema = Dict{String,Any}( diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl new file mode 100644 index 0000000..d100f9b --- /dev/null +++ b/src/tools/getTime.jl @@ -0,0 +1,49 @@ +""" +Execute the get_time tool. + +# Arguments +- `toolCallId::String`: Unique identifier for this tool call +- `args::Dict{String,Any}`: Parsed arguments from the LLM +- `signal::Union{Nothing,abortSignal}`: Optional abort signal +- `onPartialResult::Function`: Callback for streaming partial results + +# Returns +- `agentToolResult`: Result content with current time +""" +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + tz = get(args, "timezone", "local") + + t = now() + + if tz == "local" + timeStr = string(t) + else + timeStr = string(t) + end + + return agentToolResult( + [textContent("Current time: $(timeStr)")], + Dict{Any,Any}(), nothing, false + ) +end + +""" +Define and return the get_time agentTool. +""" +function getTool()::agentTool + return agentTool( + name = "get_time", + label = "Get Current Time", + description = "Get the current date and time.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "timezone" => Dict("type" => "string", "description" => "Timezone (currently only 'local' is supported)") + ), + "required" => [] + ), + execute = executeTool, + prepareArguments = nothing, + parallelToolExecute = false + ) +end diff --git a/src/tools/get_weather.jl b/src/tools/getWeather.jl similarity index 89% rename from src/tools/get_weather.jl rename to src/tools/getWeather.jl index 9b23aef..f62cb80 100644 --- a/src/tools/get_weather.jl +++ b/src/tools/getWeather.jl @@ -1,5 +1,5 @@ """ -Execute the get_weather tool. +Execute the getWeather tool. # Arguments - `toolCallId::String`: Unique identifier for this tool call @@ -10,7 +10,7 @@ Execute the get_weather tool. # Returns - `agentToolResult`: Result content with weather data """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) :: agentToolResult +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult city = get(args, "city", "") units = get(args, "units", "celsius") @@ -37,11 +37,11 @@ function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{N end """ -Define and return the get_weather agentTool. +Define and return the getWeather agentTool. """ -function getTool() :: agentTool +function getTool()::agentTool return agentTool( - name = "get_weather", + name = "getWeather", label = "Weather Lookup", description = "Fetch current weather and forecast for a given city.", inputSchema = Dict{String,Any}( diff --git a/src/tools/registry.jl b/src/tools/registry.jl index b4cb46a..5ce045f 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -11,17 +11,17 @@ const _registry = Vector{agentTool}() Load all tool modules from a directory. Scans `dir` for `.jl` files. Each file must define a function named -`getTool() :: agentTool`. Files are sorted alphabetically so tool +`getTool()::agentTool`. Files are sorted alphabetically so tool registration order is deterministic. # Tool file format Each `.jl` file defines one function `getTool()` that returns an `agentTool`: ```julia -# src/tools/get_weather.jl -function getTool() :: agentTool +# src/tools/getWeather.jl +function getTool()::agentTool return agentTool( - name = "get_weather", + name = "getWeather", label = "Weather Lookup", description = "Fetch current weather and forecast for a given city.", inputSchema = Dict{String,Any}( @@ -72,10 +72,10 @@ function loadTools(dir::String)::Vector{agentTool} include(filepath) # Validate that getTool was defined (include() places it in current module scope) - if !isdefined(:getTool) + if !isdefined(@__MODULE__, :getTool) throw(ArgumentError( "Tool file $(filepath) does not define a `getTool()` function. " * - "Each tool file must define: function getTool() :: agentTool ... end" + "Each tool file must define: function getTool()::agentTool ... end" )) end diff --git a/src/type.jl b/src/type.jl index 66d4cda..5146870 100644 --- a/src/type.jl +++ b/src/type.jl @@ -215,7 +215,7 @@ Maps MCP server tool definitions to an executable Julia tool. # MCP Tool Example ``` { - "name": "get_weather", + "name": "getWeather", "title": "Weather Lookup", "description": "Fetch current weather and forecast for a given city.", "inputSchema": { @@ -232,7 +232,7 @@ Maps MCP server tool definitions to an executable Julia tool. # Example ```julia tool = agentTool( - name="get_weather", + name="getWeather", label="Weather Lookup", description="Fetch current weather and forecast for a given city.", inputSchema=Dict( diff --git a/src_OLD/OLD_interface.jl b/src_OLD/OLD_interface.jl index 8c1ca5c..5e375d6 100644 --- a/src_OLD/OLD_interface.jl +++ b/src_OLD/OLD_interface.jl @@ -140,14 +140,14 @@ function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 }, "action_name": { "type": "string", - "enum": ["search_web", "get_weather", "calculate_math"], + "enum": ["search_web", "getWeather", "calculate_math"], "description": "The exact name of the tool to execute." }, "action_input": { "type": "object", "properties": { "query": { "type": ["string", "null"], "description": "For search_web" }, - "location": { "type": ["string", "null"], "description": "For get_weather" }, + "location": { "type": ["string", "null"], "description": "For getWeather" }, "equation": { "type": ["string", "null"], "description": "For calculate_math" } }, "required": ["query", "location", "equation"], -- 2.52.0 From 0ddbcf9ca1b2ca2bf1784bd81d845c7b97c82325 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 09:38:05 +0700 Subject: [PATCH 050/112] update --- src/tools/README.md | 383 ++++++++++++++++++++++++++++++++++++++++ src/tools/getTime.jl | 68 +++++-- src/tools/getWeather.jl | 9 +- src/type.jl | 12 +- src/utils.jl | 103 ++++++++++- 5 files changed, 548 insertions(+), 27 deletions(-) create mode 100644 src/tools/README.md diff --git a/src/tools/README.md b/src/tools/README.md new file mode 100644 index 0000000..d7c6380 --- /dev/null +++ b/src/tools/README.md @@ -0,0 +1,383 @@ +# Tools + +Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does). + +## Quick Start + +Add a new tool by creating a `.jl` file in `src/tools/`. The file must define a `getTool()` function that returns an `agentTool`: + +```julia +# src/tools/my_tool.jl + +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + city = args["city"] + return agentToolResult( + [textContent("Hello from $(city)!")], + Dict{Any,Any}(), nothing, false + ) +end + +function getTool()::agentTool + return agentTool( + name = "my_tool", + label = "My Tool", + description = "Says hello to a city.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City name") + ), + "required" => ["city"] + ), + execute = executeTool, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +``` + +When `loadTools()` or `registerTool()` is called, the tool becomes available to the agent. + +## Tool Anatomy + +Each tool has 3 main parts: + +### 1. Schema (`inputSchema`) + +JSON Schema (MCP format) describing the tool's arguments. The `"required"` array lists mandatory fields: + +```julia +inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string", "description" => "City name"), + "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") + ), + "required" => ["city"] +) +``` + +### 2. Execution Function (`execute`) + +A function with the signature: + +```julia +execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult +``` + +- **`toolCallId`** — unique ID for this invocation (from the LLM's tool call) +- **`args`** — validated arguments provided by the LLM +- **`signal`** — abort signal for cancellable operations +- **`onPartialResult`** — callback for streaming progress updates +- **Returns** — `agentToolResult` with content, details, usage, and termination flag + +```julia +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + # Optional: stream progress updates + onPartialResult(Dict("status" => "Fetching data...")) + + # Do work + result = "Weather in $(args["city"]): Sunny, 22°C" + + # Return result + return agentToolResult( + [textContent(result)], + Dict{Any,Any}(), # details + nothing, # usage + false # terminate (true to stop agent loop) + ) +end +``` + +### 3. Tool Definition (`getTool()`) + +Returns an `agentTool` struct: + +| Field | Type | Description | +|---|---|---| +| `name` | `String` | Unique identifier (e.g. `"getWeather"`) | +| `label` | `String` | Human-readable name (e.g. `"Weather Lookup"`) | +| `description` | `String` | What the tool does (shown to the LLM) | +| `inputSchema` | `Any` | JSON Schema (MCP format) | +| `execute` | `Function` | The execution function | +| `prepareArguments` | `Union{Function,Nothing}` | Optional argument transform before validation | +| `validateRequiredArgs` | `Union{Function,Nothing}` | Optional custom validation | +| `parallelToolExecute` | `Bool` | Run this tool in parallel with others | + +## Argument Validation + +Validation happens **before** tool execution, in the `prepareToolCall` phase. Invalid calls return an error immediately without invoking `execute`, `beforeToolCall`, or logging `toolExecutionStart`. + +### Default: JSON Schema Required Fields + +Set `validateRequiredArgs = nothing` to use the default validator, which checks that all fields in `inputSchema["required"]` are present: + +```julia +# src/tools/getWeather.jl — uses default validation +function getTool()::agentTool + return agentTool( + name = "getWeather", + # ... + validateRequiredArgs = nothing, # uses default + ) +end +``` + +### Custom Validation Hook + +Override `validateRequiredArgs` when you need: +- **Cross-field constraints** (e.g. "at least one of X or Y") +- **Format validation** (e.g. regex patterns, date parsing) +- **Domain rules** (e.g. value ranges, business logic) + +The hook signature takes only `args`: + +```julia +function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} + tz = get(args, "timezone", nothing) + city = get(args, "city", "") + + if !haskey(args, "timezone") && isempty(city) + return "Missing required argument: provide at least one of 'timezone' or 'city'" + end + + if tz !== nothing + tz_str = string(tz) + if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) + return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York'" + end + end + + return nothing +end +``` + +Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. + +## Tool Call Lifecycle + +``` +LLM requests tool call + └── prepareToolCall (agentCore.jl:511) + ├── Tool lookup by name + ├── prepareArguments (tool-specific transform, if defined) + ├── validateToolArguments (validateRequiredArgs hook or default) + │ └── on failure → immediateOutcome (no execution) + ├── beforeToolCall hook (if defined) + │ └── on block → immediateOutcome (no execution) + └── returns preparedToolCall + +executed by executeToolCallsSequential or executeToolCallsParallel + └── executePreparedToolCall (agentCore.jl:589) + ├── emit toolExecutionStart + ├── call tool.execute() + │ └── on error → executedOutcome(isError=true) + └── returns executedOutcome + +finalizeExecutedToolCall (agentCore.jl:675) + ├── afterToolCall hook (if defined) + │ └── can mutate result content, usage, terminate, isError + └── returns finalizedOutcome + +emit toolExecutionEnd + └── createToolResultMessage → added to conversation history +``` + +## Execution Modes + +### Sequential + +Tools execute one at a time in order. Required when: +- Tools have implicit dependencies +- Tools share state (e.g. writing to the same file) +- Tools have `parallelToolExecute = false` + +Set globally via `agentLoopConfig.toolExecution = "sequential"`, or per-tool via `parallelToolExecute = false`. + +### Parallel + +Tools execute concurrently when all are independent. Reduces wall-clock time. Set `parallelToolExecute = true` on individual tools, or set `agentLoopConfig.toolExecution = "parallel"`. + +## Streaming Partial Results + +For long-running tools (API calls, file uploads, training), use `onPartialResult` to stream progress: + +```julia +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + onPartialResult(Dict("status" => "Step 1: Fetching data...")) + sleep(1) + + onPartialResult(Dict("status" => "Step 2: Processing...")) + sleep(1) + + return agentToolResult( + [textContent("Done!")], + Dict{Any,Any}(), nothing, false + ) +end +``` + +UI listeners and the TUI consume these events in real time via `toolExecutionUpdate`. + +## Loading Tools + +### Auto-load from Directory + +```julia +using .toolRegistry + +tools = loadTools("src/tools") # scans for *.jl files with getTool() +``` + +Files are loaded alphabetically for deterministic registration order. + +### Manual Registration + +```julia +tool = getTool() # from your tool module +registerTool(tool) +``` + +## Using Tools with an Agent + +Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow: + +```julia +using .YiemAgent +using .toolRegistry + +# 1. Load tools from the tools directory +tools = loadTools("src/tools") +# [toolRegistry] Loading tool from: src/tools/getTime.jl +# [toolRegistry] Loaded tool: getTime — Time Lookup +# [toolRegistry] Loading tool from: src/tools/getWeather.jl +# [toolRegistry] Loaded tool: getWeather — Weather Lookup + +# 2. Define your LLM call function +function my_llm_call(messages::Dict)::assistantMessage + # Call your LLM API here (OpenAI, Anthropic, local model, etc.) + # Return an assistantMessage with the response content + # If the LLM wants to call a tool, include tool_call content blocks + ... +end + +# 3. Define your event sink (optional, for logging/debugging) +function my_event_sink(event) + if event isa toolExecStartEvent + println("[EVENT] Tool start: $(event.toolName)") + elseif event isa toolExecEndEvent + status = event.isError ? "ERROR" : "OK" + println("[EVENT] Tool end: $(event.toolName) — $status") + end +end + +# 4. Create the agent with tools +agent = yiemAgent( + systemPrompt = "You are a helpful assistant that can check weather and time.", + model = my_model, + tools = tools, # pass loaded tools + llmCall = my_llm_call, # your LLM function + agentEventSink = my_event_sink, # event handler +) + +# 5. Send a message and get a response +run_agent(agent, "What's the weather in Tokyo?") +response = take_response(agent) + +# response.content contains the LLM's reply (with tool results if applicable) +println(response.content) + +# 6. When done, stop the agent +stop_agent(agent) +``` + +### How It Works + +1. **User sends a message** via `run_agent(agent, "What's the weather in Tokyo?")`. The message goes into `inputChannel`. + +2. **Agent loop** (`_agent_loop`) picks it up, converts it to a `userMessage`, and adds it to `agent._state.messages`. + +3. **LLM is called** via `agent.llmCall(formatted_messages)`. The LLM sees the system prompt, conversation history, and the tool definitions in the prompt (via `formatMsgForLLM`). + +4. **If the LLM uses a tool**, it returns a response with `tool_call` content blocks. The agent: + - Extracts each tool call (name, arguments) + - Runs validation (`validateRequiredArgs` or default) + - Executes the tool (or returns an error if validation fails) + - Feeds the result back as a `toolResultMessage` in the conversation + +5. **LLM is called again** with the tool results. This repeats until the LLM returns a text response with no tool calls. + +6. **Final response** is sent to `outputChannel` — retrieve it with `take_response(agent)`. + +### Minimal Working Example + +```julia +using .YiemAgent +using .toolRegistry + +# Load tools +tools = loadTools("src/tools") + +# Mock LLM that echoes back a tool call, then a text response +call_count = 0 +function mock_llm_call(messages::Dict)::assistantMessage + global call_count += 1 + if call_count == 1 + # First call: LLM decides to use getWeather + return assistantMessage( + content=[ + Dict("type" => "tool_calls", + "tool_calls" => [Dict("id" => "call_1", "name" => "getWeather", + "arguments" => Dict("city" => "Tokyo"))]) + ], + model = "mock", + usage = llmUsage(0, 0) + ) + else + # Second call: LLM returns text (after tool result) + return assistantMessage( + content = [textContent("The weather in Tokyo is sunny, 22°C.")], + model = "mock", + usage = llmUsage(0, 0) + ) + end +end + +# Create agent +agent = yiemAgent( + systemPrompt = "You are a helpful assistant.", + tools = tools, + llmCall = mock_llm_call, + agentEventSink = e -> nothing, # no events +) + +# Run +run_agent(agent, "What's the weather in Tokyo?") +response = take_response(agent) + +stop_agent(agent) +``` + +## Available Tools + +| Tool | Description | Validation | +|---|---|---| +| `getWeather` | Fetch weather for a city | Default (JSON Schema required) | +| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | + +## Example: Error Flow + +When the LLM calls a tool with invalid arguments: + +``` +User: "What's the weather?" + └── LLM: call getWeather() with no arguments + └── prepareToolCall → validateRequiredArgs → "Missing required arguments: city" + └── immediateOutcome → error tool result + └── LLM sees: "Missing required arguments: city" + └── LLM retries: call getWeather(city="Tokyo") + └── executeTool → "Weather in Tokyo: Sunny, 22°C" +``` + +The agent feeds the error back to the LLM as a tool result message, allowing it to self-correct. diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index d100f9b..97636ab 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -1,5 +1,43 @@ """ -Execute the get_time tool. +Validate required arguments for the getTime tool. + +Demonstrates custom validation beyond simple required-field checking: +- Ensures at least one time source (timezone or city) is provided +- Validates timezone is in IANA format if specified +- Validates city name is not empty if specified + +# Arguments +- `args::Dict{String,Any}`: Arguments from the LLM + +# Returns +- `nothing` if validation passes +- `String` error message if validation fails +""" +function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} + tz = get(args, "timezone", nothing) + city = get(args, "city", "") + + hasTz = tz !== nothing && !isempty(tz) + hasCity = !isempty(city) + + # At least one of timezone or city is required + if !hasTz && !hasCity + return "Missing required argument: provide at least one of 'timezone' or 'city'" + end + + # Validate timezone format (IANA tz database: "Continent/City" or "Continent/City/SubCity") + if hasTz + tz_str = string(tz) + if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) + return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York' or 'Asia/Tokyo'" + end + end + + return nothing +end + +""" +Execute the getTime tool. # Arguments - `toolCallId::String`: Unique identifier for this tool call @@ -8,42 +46,44 @@ Execute the get_time tool. - `onPartialResult::Function`: Callback for streaming partial results # Returns -- `agentToolResult`: Result content with current time +- `agentToolResult`: Result content with current time data """ function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - tz = get(args, "timezone", "local") + tz = get(args, "timezone", nothing) + city = get(args, "city", "") - t = now() - - if tz == "local" - timeStr = string(t) + # Simulate time lookup — replace with actual timezone API call + if tz !== nothing + result = "Current time in $(tz): $(now())" else - timeStr = string(t) + result = "Current time in $(city): $(now())" end return agentToolResult( - [textContent("Current time: $(timeStr)")], + [textContent(result)], Dict{Any,Any}(), nothing, false ) end """ -Define and return the get_time agentTool. +Define and return the getTime agentTool. """ function getTool()::agentTool return agentTool( - name = "get_time", - label = "Get Current Time", - description = "Get the current date and time.", + name = "getTime", + label = "Time Lookup", + description = "Get current local time for a timezone or city.", inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict( - "timezone" => Dict("type" => "string", "description" => "Timezone (currently only 'local' is supported)") + "timezone" => Dict("type" => "string", "description", "IANA timezone, e.g. 'America/New_York'"), + "city" => Dict("type" => "string", "description", "City name as fallback") ), "required" => [] ), execute = executeTool, prepareArguments = nothing, + validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false ) end diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index f62cb80..0411841 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -14,14 +14,6 @@ function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{N city = get(args, "city", "") units = get(args, "units", "celsius") - # Validate required arguments - if isempty(city) - return agentToolResult( - [textContent("Error: 'city' argument is required.")], - Dict{Any,Any}(), nothing, false - ) - end - # Simulate weather fetch — replace with actual API call # You can call onPartialResult() here for streaming progress updates: # onPartialResult(Dict("status" => "Fetching weather data...")) @@ -54,6 +46,7 @@ function getTool()::agentTool ), execute = executeTool, # reference the function defined above prepareArguments = nothing, + validateRequiredArgs = nothing, parallelToolExecute = false ) end diff --git a/src/type.jl b/src/type.jl index 5146870..60ae3c5 100644 --- a/src/type.jl +++ b/src/type.jl @@ -8,8 +8,8 @@ textContent, imageContent, # Message types userMessage, assistantMessage, toolResultMessage, - # Tool types - agentTool, + # Tool types + agentTool, validateRequiredArgs # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types @@ -207,6 +207,8 @@ Maps MCP server tool definitions to an executable Julia tool. - `execute::Function`: Tool execution function, signature: `execute(toolCallId::String, args::Dict, signal::Union{Nothing,AbortSignal}, onPartialResult::Function)` - `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback +- `validateRequiredArgs::Union{Function, Nothing}`: Optional validation hook, signature: + `validateRequiredArgs(args::Dict) -> Union{Nothing, String}` where `String` is an error message - `parallelToolExecute::Bool`: Override: run tool calls sequentially or in parallel # Returns @@ -249,7 +251,10 @@ tool = agentTool( [textContent("Sunny, 22C in $(city)")], Dict{Any,Any}(), nothing, false ) - end + end, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false ) ``` """ @@ -260,6 +265,7 @@ struct agentTool # A tool available to the agent inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) execute::Function # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback + validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel end diff --git a/src/utils.jl b/src/utils.jl index 0c92f43..db3e51d 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,6 +1,6 @@ module utils -export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, _userMessageToOpenAI, +export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks using UUIDs, Dates, DataStructures, HTTP, JSON @@ -275,10 +275,109 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{ end end - return blocks + return blocks 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 + + -- 2.52.0 From d29a413159f006ea55b7c05ef7d8ead80fe44cde Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 11:09:00 +0700 Subject: [PATCH 051/112] update --- src/tools/README.md | 229 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 207 insertions(+), 22 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index d7c6380..7262450 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -155,33 +155,218 @@ end Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. -## Tool Call Lifecycle +## Tool Lifecycle — Framework Internals + +This section traces the full code path from the moment the LLM returns tool calls to the final result being fed back into the conversation. All code references are to `agentCore.jl`. + +### Phase 1: Detect Tool Calls in LLM Response + +After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block: + +```julia +# agentCore.jl:217-244 +has_tool_calls = false +tool_call_list = agentToolCall[] + +for content_block in response.content + if content_block isa Dict + # OpenAI-style: type == "tool_calls" with array of tool calls + if get(content_block, :type, "") == "tool_calls" + for tc_data in get(content_block, :tool_calls, []) + tc = agentToolCall( + type="function", + id=get(tc_data, :id, string(uuid4())), + name=get(tc_data, :function, Dict{String,Any}())[:name], + arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], + ) + push!(tool_call_list, tc) + end + # Alternative style: type == "tool_call" single dict per block + elseif get(content_block, :type, "") == "tool_call" + tc = agentToolCall( + type="function", + id=get(tc_data, :id, string(uuid4())), + name=get(tc_data, :name, ""), + arguments=get(tc_data, :arguments, Dict{String,Any}()), + ) + push!(tool_call_list, tc) + end + end +end +``` + +Each content block with `type == "tool_calls"` or `type == "tool_call"` extracts an `agentToolCall` (id, name, arguments dict) and collects them into a `Vector{agentToolCall}`. + +### Phase 2: Dispatch to Sequential or Parallel Execution + +At `agentCore.jl:247`, the framework checks if any tool calls exist and decides execution mode: + +```julia +# agentCore.jl:247-265 +context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) +config = agentLoopConfig( + agent._state.tools, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute ? "parallel" : "sequential", +) +batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) +``` + +`executeToolCalls` (`agentCore.jl:988-1015`) checks: +- `config.toolExecution == "sequential"` → sequential mode +- Any tool has `parallelToolExecute == false` → sequential mode +- Otherwise → parallel mode + +### Phase 3: Per-Call Preparation (`prepareToolCall`) + +Each tool call goes through `prepareToolCall` (`agentCore.jl:511-547`): ``` -LLM requests tool call - └── prepareToolCall (agentCore.jl:511) - ├── Tool lookup by name - ├── prepareArguments (tool-specific transform, if defined) - ├── validateToolArguments (validateRequiredArgs hook or default) - │ └── on failure → immediateOutcome (no execution) - ├── beforeToolCall hook (if defined) - │ └── on block → immediateOutcome (no execution) - └── returns preparedToolCall +1. Look up tool by name: find(t -> t.name == tc.name, context.tools) +2. If not found → immediateOutcome("Tool X not found", true) +3. Run tool.prepareArguments (if defined) → transforms raw LLM args +4. Run validateToolArguments → validateRequiredArgs (hook or default) + → if fails → throws ArgumentError → caught below +5. Run beforeToolCall hook (if defined) → can block execution + → if blocked → immediateOutcome("Tool execution was blocked", true) +6. Return preparedToolCall(tool, tc, validatedArgs) +``` -executed by executeToolCallsSequential or executeToolCallsParallel - └── executePreparedToolCall (agentCore.jl:589) - ├── emit toolExecutionStart - ├── call tool.execute() - │ └── on error → executedOutcome(isError=true) - └── returns executedOutcome +If any step throws (validation, prepareArguments, beforeToolCall), the catch block at `agentCore.jl:545` converts it to an `immediateOutcome`: -finalizeExecutedToolCall (agentCore.jl:675) - ├── afterToolCall hook (if defined) - │ └── can mutate result content, usage, terminate, isError - └── returns finalizedOutcome +```julia +catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) +end +``` -emit toolExecutionEnd - └── createToolResultMessage → added to conversation history +### Phase 4: Execution (`executePreparedToolCall`) + +For each `preparedToolCall`, `executePreparedToolCall` (`agentCore.jl:589-617`) runs: + +```julia +function executePreparedToolCall(prep::preparedToolCall, signal, emit)::executedOutcome + updateEvents = promise[] + accepting = true + + try + result = prep.tool.execute( + prep.toolCall.id, prep.args, signal, + partialResult -> begin + if accepting + push!(updateEvents, emit(toolExecUpdateEvent(..., partialResult))) + end + end + ) + accepting = false + wait.(updateEvents) + return executedOutcome(result, false) + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end +end +``` + +Key behaviors: +- Calls `tool.execute(id, args, signal, onPartialResult)` — your tool's `executeTool` function +- `signal` can be checked inside `executeTool` for cancellation +- `onPartialResult` is called for streaming updates, which are emitted as `toolExecutionUpdate` events +- `accepting` guard prevents emitting updates after the result is already captured +- `wait.(updateEvents)` ensures all streaming updates are delivered before returning +- Execution errors are caught and returned as `executedOutcome(isError=true)` — never thrown + +### Phase 5: Finalization (`finalizeExecutedToolCall`) + +After execution, `finalizeExecutedToolCall` (`agentCore.jl:675-706`) runs the `afterToolCall` hook: + +```julia +function finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)::finalizedOutcome + result = executed.result + isError = executed.isError + + if config.afterToolCall !== nothing + try + after = config.afterToolCall(afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal) + if after !== nothing + # Hook can mutate: content, details, usage, terminate, isError + result = merge(result, dict(...)) + isError = get(after, :isError, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true + end + end + + return finalizedOutcome(prep.toolCall, result, isError) +end +``` + +The hook can: +- Mask sensitive data from result content +- Normalize usage tracking +- Flip `terminate: true` based on business logic +- Wrap errors in friendlier messages for the LLM + +If the hook itself throws, the error is caught and converted to an error outcome. + +### Phase 6: Emit Events and Create Result Message + +Each call emits `toolExecutionEnd`: + +```julia +function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) +end +``` + +Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`): + +```julia +function createToolResultMessage(f::finalizedOutcome)::toolResultMessage + return toolResultMessage( + "toolResult", f.toolCall.id, f.toolCall.name, + f.result.content, f.result.details, f.result.usage, + get(f.result, :addedToolNames, string[]), f.isError, nowMillis() + ) +end +``` + +### Phase 7: Batch Assembly and Loop Control + +In `executeToolCallsSequential` (`agentCore.jl:795-829`) or `executeToolCallsParallel` (`agentCore.jl:888-936`), all results are collected: + +```julia +messages = toolResultMessage[] +for finalized in finalizedCalls + push!(messages, createToolResultMessage(finalized)) +end +return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) +``` + +`shouldTerminate` (`agentCore.jl:409`) returns `true` only if ALL tools in the batch set `result.terminate == true`. If `false`, the agent loop at `agentCore.jl:176-308` feeds the tool results back to the LLM for another turn. + +### Data Flow Summary + +``` +response.content (Vector{Any}) + └── phase 1: parse content blocks + └── tool_call_list :: Vector{agentToolCall} + └── phase 2: dispatch to sequential/parallel + └── phase 3: prepareToolCall + └── preparedToolCall or immediateOutcome + └── phase 4: executePreparedToolCall + └── executedOutcome + └── phase 5: finalizeExecutedToolCall + └── finalizedOutcome + └── phase 6: createToolResultMessage + └── toolResultMessage + └── phase 7: agentToolCallBatch + └── pushed to agent._state.messages + └── loop back to LLM ``` ## Execution Modes -- 2.52.0 From 75b2ce59788eeba6964a332a84126c4ef6701d3a Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 12:11:53 +0700 Subject: [PATCH 052/112] update --- src/tools/README.md | 547 +++++++++++++++----------------------------- 1 file changed, 190 insertions(+), 357 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index 7262450..0a3a8a8 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -2,43 +2,6 @@ Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does). -## Quick Start - -Add a new tool by creating a `.jl` file in `src/tools/`. The file must define a `getTool()` function that returns an `agentTool`: - -```julia -# src/tools/my_tool.jl - -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - city = args["city"] - return agentToolResult( - [textContent("Hello from $(city)!")], - Dict{Any,Any}(), nothing, false - ) -end - -function getTool()::agentTool - return agentTool( - name = "my_tool", - label = "My Tool", - description = "Says hello to a city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City name") - ), - "required" => ["city"] - ), - execute = executeTool, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -``` - -When `loadTools()` or `registerTool()` is called, the tool becomes available to the agent. - ## Tool Anatomy Each tool has 3 main parts: @@ -155,220 +118,177 @@ end Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. -## Tool Lifecycle — Framework Internals +## Tool Discovery and Lifecycle -This section traces the full code path from the moment the LLM returns tool calls to the final result being fed back into the conversation. All code references are to `agentCore.jl`. +The agent iterates through tools via a **discover → execute → loop** cycle. Here is the complete flow from the framework author's perspective: -### Phase 1: Detect Tool Calls in LLM Response - -After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block: +### The Agent Loop ```julia -# agentCore.jl:217-244 -has_tool_calls = false +# agentCore.jl:175 - _process_message() +while true + # 1. Drain messages from inputChannel + while isready(agent.inputChannel) + raw_msg = take!(agent.inputChannel) + user_msg = OpenAiToUserMessage(raw_msg) + push!(agent._state.messages, user_msg) + end + + # 2. Format messages for LLM + ctx = agent.prepareContext(agent._state) + formatted = agent.formatMsgForLLM(ctx) + + # 3. Call LLM + response = agent.llmCall(formatted) + + # 4. Check if LLM used tool calls + if has_tool_calls(response.content) + # 5. Execute tools, feed results back to LLM, loop + else + # 6. No tool calls — return final response + break + end +end +``` + +### Step 1: Tool Discovery + +Tools are discovered from `agent._state.tools`, which is a `Vector{agentTool}` populated during agent creation: + +```julia +# Loading tools +tools = loadTools("src/tools") # returns Vector{agentTool} + +# Passing to agent +agent = yiemAgent( + systemPrompt = "...", + tools = tools, # ← tools stored in agent._state.tools + llmCall = my_llm_call, + agentEventSink = my_event_sink, +) +``` + +When the LLM response contains tool calls, the agent builds an `agentContext` with those tools: + +```julia +context = agentContext( + agent._state.systemPrompt, + agent._state.messages, + agent._state.tools, # ← tools available for discovery +) +``` + +### Step 2: Extract Tool Calls from LLM Response + +The agent inspects the `response.content` blocks for `tool_calls`: + +```julia +# agentCore.jl:217-245 tool_call_list = agentToolCall[] for content_block in response.content - if content_block isa Dict - # OpenAI-style: type == "tool_calls" with array of tool calls - if get(content_block, :type, "") == "tool_calls" - for tc_data in get(content_block, :tool_calls, []) - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :function, Dict{String,Any}())[:name], - arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], - ) - push!(tool_call_list, tc) - end - # Alternative style: type == "tool_call" single dict per block - elseif get(content_block, :type, "") == "tool_call" - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :name, ""), - arguments=get(tc_data, :arguments, Dict{String,Any}()), - ) - push!(tool_call_list, tc) - end - end -end -``` - -Each content block with `type == "tool_calls"` or `type == "tool_call"` extracts an `agentToolCall` (id, name, arguments dict) and collects them into a `Vector{agentToolCall}`. - -### Phase 2: Dispatch to Sequential or Parallel Execution - -At `agentCore.jl:247`, the framework checks if any tool calls exist and decides execution mode: - -```julia -# agentCore.jl:247-265 -context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) -config = agentLoopConfig( - agent._state.tools, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute ? "parallel" : "sequential", -) -batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) -``` - -`executeToolCalls` (`agentCore.jl:988-1015`) checks: -- `config.toolExecution == "sequential"` → sequential mode -- Any tool has `parallelToolExecute == false` → sequential mode -- Otherwise → parallel mode - -### Phase 3: Per-Call Preparation (`prepareToolCall`) - -Each tool call goes through `prepareToolCall` (`agentCore.jl:511-547`): - -``` -1. Look up tool by name: find(t -> t.name == tc.name, context.tools) -2. If not found → immediateOutcome("Tool X not found", true) -3. Run tool.prepareArguments (if defined) → transforms raw LLM args -4. Run validateToolArguments → validateRequiredArgs (hook or default) - → if fails → throws ArgumentError → caught below -5. Run beforeToolCall hook (if defined) → can block execution - → if blocked → immediateOutcome("Tool execution was blocked", true) -6. Return preparedToolCall(tool, tc, validatedArgs) -``` - -If any step throws (validation, prepareArguments, beforeToolCall), the catch block at `agentCore.jl:545` converts it to an `immediateOutcome`: - -```julia -catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) -end -``` - -### Phase 4: Execution (`executePreparedToolCall`) - -For each `preparedToolCall`, `executePreparedToolCall` (`agentCore.jl:589-617`) runs: - -```julia -function executePreparedToolCall(prep::preparedToolCall, signal, emit)::executedOutcome - updateEvents = promise[] - accepting = true - - try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, emit(toolExecUpdateEvent(..., partialResult))) + if content_block isa Dict + if get(content_block, :type, "") == "tool_calls" + # OpenAI format: {"type": "tool_calls", "tool_calls": [...]} + for tc_data in get(content_block, :tool_calls, []) + tc = agentToolCall( + type = "function", + id = get(tc_data, :id, string(uuid4())), + name = get(tc_data, :function, Dict())[:name], + arguments = get(tc_data, :function, Dict())[:arguments], + ) + push!(tool_call_list, tc) + end + elseif get(content_block, :type, "") == "tool_call" + # Alternative format: single tool_call block + tc = agentToolCall( + type = "function", + id = get(content_block, :id, string(uuid4())), + name = get(content_block, :name, ""), + arguments = get(content_block, :arguments, Dict()), + ) + push!(tool_call_list, tc) end - end - ) - accepting = false - wait.(updateEvents) - return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end -end -``` - -Key behaviors: -- Calls `tool.execute(id, args, signal, onPartialResult)` — your tool's `executeTool` function -- `signal` can be checked inside `executeTool` for cancellation -- `onPartialResult` is called for streaming updates, which are emitted as `toolExecutionUpdate` events -- `accepting` guard prevents emitting updates after the result is already captured -- `wait.(updateEvents)` ensures all streaming updates are delivered before returning -- Execution errors are caught and returned as `executedOutcome(isError=true)` — never thrown - -### Phase 5: Finalization (`finalizeExecutedToolCall`) - -After execution, `finalizeExecutedToolCall` (`agentCore.jl:675-706`) runs the `afterToolCall` hook: - -```julia -function finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)::finalizedOutcome - result = executed.result - isError = executed.isError - - if config.afterToolCall !== nothing - try - after = config.afterToolCall(afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal) - if after !== nothing - # Hook can mutate: content, details, usage, terminate, isError - result = merge(result, dict(...)) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true end - end - - return finalizedOutcome(prep.toolCall, result, isError) end ``` -The hook can: -- Mask sensitive data from result content -- Normalize usage tracking -- Flip `terminate: true` based on business logic -- Wrap errors in friendlier messages for the LLM +### Step 3: Execute Each Tool Call -If the hook itself throws, the error is caught and converted to an error outcome. - -### Phase 6: Emit Events and Create Result Message - -Each call emits `toolExecutionEnd`: +For each tool call, the agent runs through the **prepare → execute → finalize** pipeline: ```julia -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) +# agentCore.jl:247-302 +if has_tool_calls && length(tool_call_list) > 0 + context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) + config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, execution_mode) + signal = nothing + emit = agent.agentEventSink + + # Execute all tool calls (sequential or parallel) + batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + + # Save results to conversation history + for tool_result in batch.messages + push!(agent._state.messages, tool_result) + end + + # If any tool requested termination, break the loop + if batch.terminate + final_response = build_final_response(batch) + break + end + # Otherwise, loop back to step 2 (format + call LLM again) end ``` -Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`): +### Step 4: The Per-Call Pipeline -```julia -function createToolResultMessage(f::finalizedOutcome)::toolResultMessage - return toolResultMessage( - "toolResult", f.toolCall.id, f.toolCall.name, - f.result.content, f.result.details, f.result.usage, - get(f.result, :addedToolNames, string[]), f.isError, nowMillis() - ) -end -``` - -### Phase 7: Batch Assembly and Loop Control - -In `executeToolCallsSequential` (`agentCore.jl:795-829`) or `executeToolCallsParallel` (`agentCore.jl:888-936`), all results are collected: - -```julia -messages = toolResultMessage[] -for finalized in finalizedCalls - push!(messages, createToolResultMessage(finalized)) -end -return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) -``` - -`shouldTerminate` (`agentCore.jl:409`) returns `true` only if ALL tools in the batch set `result.terminate == true`. If `false`, the agent loop at `agentCore.jl:176-308` feeds the tool results back to the LLM for another turn. - -### Data Flow Summary +Each tool call goes through three phases: ``` -response.content (Vector{Any}) - └── phase 1: parse content blocks - └── tool_call_list :: Vector{agentToolCall} - └── phase 2: dispatch to sequential/parallel - └── phase 3: prepareToolCall - └── preparedToolCall or immediateOutcome - └── phase 4: executePreparedToolCall - └── executedOutcome - └── phase 5: finalizeExecutedToolCall - └── finalizedOutcome - └── phase 6: createToolResultMessage - └── toolResultMessage - └── phase 7: agentToolCallBatch - └── pushed to agent._state.messages - └── loop back to LLM +┌─────────────────────────────────────────────────────────────────┐ +│ PREPARE → prepareToolCall() │ +│ │ +│ 1. Find tool by name in context.tools │ +│ 2. Transform args via tool.prepareArguments (if defined) │ +│ 3. Validate via tool.validateRequiredArgs (or default) │ +│ 4. Run beforeToolCall hook (if defined) │ +│ └── on any failure → return immediateOutcome (skip execution) │ +│ └── success → return preparedToolCall │ +├─────────────────────────────────────────────────────────────────┤ +│ EXECUTE → executePreparedToolCall() │ +│ │ +│ 1. emit toolExecutionStart event │ +│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│ +│ 3. wait for all pending update events │ +│ └── on error → return executedOutcome(isError=true) │ +│ └── success → return executedOutcome(isError=false) │ +├─────────────────────────────────────────────────────────────────┤ +│ FINALIZE → finalizeExecutedToolCall() │ +│ │ +│ 1. Run afterToolCall hook (if defined) │ +│ - can mutate content, details, usage, terminate, isError │ +│ 2. emit toolExecutionEnd event │ +│ 3. createToolResultMessage → adds to conversation history │ +│ └── return finalizedOutcome │ +└─────────────────────────────────────────────────────────────────┘ ``` +### Step 5: Feed Results Back to LLM + +Tool results are added to `agent._state.messages` as `toolResultMessage` objects. On the next loop iteration, `formatMsgForLLM()` converts them to OpenAI format and the LLM receives the results: + +``` +Conversation history after tool execution: + [system] "You are a helpful assistant." + [user] "What's the weather in Tokyo?" + [assistant] (tool_calls: getWeather(city="Tokyo")) + [tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" +``` + +The LLM then decides: call another tool, or return a final text answer. + ## Execution Modes ### Sequential @@ -424,124 +344,53 @@ tool = getTool() # from your tool module registerTool(tool) ``` -## Using Tools with an Agent - -Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow: +## Complete Lifecycle Example ```julia -using .YiemAgent -using .toolRegistry - -# 1. Load tools from the tools directory -tools = loadTools("src/tools") -# [toolRegistry] Loading tool from: src/tools/getTime.jl -# [toolRegistry] Loaded tool: getTime — Time Lookup -# [toolRegistry] Loading tool from: src/tools/getWeather.jl -# [toolRegistry] Loaded tool: getWeather — Weather Lookup - -# 2. Define your LLM call function -function my_llm_call(messages::Dict)::assistantMessage - # Call your LLM API here (OpenAI, Anthropic, local model, etc.) - # Return an assistantMessage with the response content - # If the LLM wants to call a tool, include tool_call content blocks - ... -end - -# 3. Define your event sink (optional, for logging/debugging) -function my_event_sink(event) - if event isa toolExecStartEvent - println("[EVENT] Tool start: $(event.toolName)") - elseif event isa toolExecEndEvent - status = event.isError ? "ERROR" : "OK" - println("[EVENT] Tool end: $(event.toolName) — $status") - end -end - -# 4. Create the agent with tools -agent = yiemAgent( - systemPrompt = "You are a helpful assistant that can check weather and time.", - model = my_model, - tools = tools, # pass loaded tools - llmCall = my_llm_call, # your LLM function - agentEventSink = my_event_sink, # event handler -) - -# 5. Send a message and get a response +# ─── USER SENDS MESSAGE ─────────────────────────────────────────── run_agent(agent, "What's the weather in Tokyo?") -response = take_response(agent) -# response.content contains the LLM's reply (with tool results if applicable) +# ─── LOOP ITERATION 1 ───────────────────────────────────────────── +# Agent formats messages and calls LLM +formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) +response = agent.llmCall(formatted) +# LLM returns: {"content": [{"type": "tool_calls", "tool_calls": [{"name": "getWeather", "arguments": {"city": "Tokyo"}}]}]} + +# Agent extracts tool call, builds context +context = agentContext(systemPrompt, messages, agent._state.tools) +tool_call_list = [agentToolCall("call_1", "getWeather", Dict("city" => "Tokyo"))] + +# PREPARE: find tool, validate args +tool = find(t -> t.name == "getWeather", context.tools) # found! +validateRequiredArgs(Dict("city" => "Tokyo"), tool.inputSchema) # passes +beforeToolCall_hook(agentMsgCtx, nothing) # nil, skipped + +# EXECUTE: call tool.execute() +result = tool.execute("call_1", Dict("city" => "Tokyo"), nothing, onPartialResult) +# Returns: agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], Dict(), nothing, false) + +# FINALIZE: afterToolCall hook, emit events +finalized = finalizedOutcome(tc, result, false) +emit(toolExecEndEvent("call_1", "getWeather", result, false)) +msg = createToolResultMessage(finalized) # toolResultMessage for conversation history + +# Add result to conversation +push!(agent._state.messages, msg) +# Messages now: [user: "What's the weather?", assistant: {tool_calls: getWeather}, tool: "Sunny, 22°C"] + +# ─── LOOP ITERATION 2 ───────────────────────────────────────────── +# LLM called again with tool result included +formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) +response = agent.llmCall(formatted) +# LLM returns: {"content": [{"type": "text", "text": "The weather in Tokyo is sunny, 22°C."}]} + +# No tool calls detected → break loop, return final response +return assistantMessage(content=[textContent("The weather in Tokyo is sunny, 22°C.")], ...) + +# ─── USER RECEIVES RESPONSE ─────────────────────────────────────── +response = take_response(agent) println(response.content) - -# 6. When done, stop the agent -stop_agent(agent) -``` - -### How It Works - -1. **User sends a message** via `run_agent(agent, "What's the weather in Tokyo?")`. The message goes into `inputChannel`. - -2. **Agent loop** (`_agent_loop`) picks it up, converts it to a `userMessage`, and adds it to `agent._state.messages`. - -3. **LLM is called** via `agent.llmCall(formatted_messages)`. The LLM sees the system prompt, conversation history, and the tool definitions in the prompt (via `formatMsgForLLM`). - -4. **If the LLM uses a tool**, it returns a response with `tool_call` content blocks. The agent: - - Extracts each tool call (name, arguments) - - Runs validation (`validateRequiredArgs` or default) - - Executes the tool (or returns an error if validation fails) - - Feeds the result back as a `toolResultMessage` in the conversation - -5. **LLM is called again** with the tool results. This repeats until the LLM returns a text response with no tool calls. - -6. **Final response** is sent to `outputChannel` — retrieve it with `take_response(agent)`. - -### Minimal Working Example - -```julia -using .YiemAgent -using .toolRegistry - -# Load tools -tools = loadTools("src/tools") - -# Mock LLM that echoes back a tool call, then a text response -call_count = 0 -function mock_llm_call(messages::Dict)::assistantMessage - global call_count += 1 - if call_count == 1 - # First call: LLM decides to use getWeather - return assistantMessage( - content=[ - Dict("type" => "tool_calls", - "tool_calls" => [Dict("id" => "call_1", "name" => "getWeather", - "arguments" => Dict("city" => "Tokyo"))]) - ], - model = "mock", - usage = llmUsage(0, 0) - ) - else - # Second call: LLM returns text (after tool result) - return assistantMessage( - content = [textContent("The weather in Tokyo is sunny, 22°C.")], - model = "mock", - usage = llmUsage(0, 0) - ) - end -end - -# Create agent -agent = yiemAgent( - systemPrompt = "You are a helpful assistant.", - tools = tools, - llmCall = mock_llm_call, - agentEventSink = e -> nothing, # no events -) - -# Run -run_agent(agent, "What's the weather in Tokyo?") -response = take_response(agent) - -stop_agent(agent) +# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" ``` ## Available Tools @@ -550,19 +399,3 @@ stop_agent(agent) |---|---|---| | `getWeather` | Fetch weather for a city | Default (JSON Schema required) | | `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | - -## Example: Error Flow - -When the LLM calls a tool with invalid arguments: - -``` -User: "What's the weather?" - └── LLM: call getWeather() with no arguments - └── prepareToolCall → validateRequiredArgs → "Missing required arguments: city" - └── immediateOutcome → error tool result - └── LLM sees: "Missing required arguments: city" - └── LLM retries: call getWeather(city="Tokyo") - └── executeTool → "Weather in Tokyo: Sunny, 22°C" -``` - -The agent feeds the error back to the LLM as a tool result message, allowing it to self-correct. -- 2.52.0 From 70296a3bf28d574571f7e0411504a5f3b6e3e472 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 12:21:52 +0700 Subject: [PATCH 053/112] update --- src/utils.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index db3e51d..fb75d77 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -113,7 +113,7 @@ function prepareContext(state::agentState)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools - #TODO add tools to current system prompt + #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 @@ -375,7 +375,7 @@ function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{S end return prepared.arguments -end +end -- 2.52.0 From 03e1dd7628d03b50f61fca331e24df5b9ec7d1b6 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 18:49:51 +0700 Subject: [PATCH 054/112] update --- src/tools/README.md | 131 ++++++++++++++++++++ src/tools/registry.jl | 51 ++++++-- src/tools/writeTool.jl | 269 +++++++++++++++++++++++++++++++++++++++++ src/type.jl | 2 +- 4 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 src/tools/writeTool.jl diff --git a/src/tools/README.md b/src/tools/README.md index 0a3a8a8..9b7df7a 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -393,9 +393,140 @@ println(response.content) # => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" ``` +## Self-Modifying Tools + +The framework includes tools that allow the agent to create new tools at runtime. + +### `writeTool` — Create New Tool Files + +The `writeTool` tool generates a new Julia tool file at `src/tools/.jl`. The agent writes the tool, then the agent (or system) restarts so `loadTools("src/tools")` picks it up. + +**Workflow:** + +1. Agent identifies a task that no existing tool can handle +2. Agent calls `writeTool` with a tool specification: + +```julia +# Agent sends this to writeTool: +Dict( + "name" => "searchWine", + "label" => "Wine Search", + "description" => "Search a wine database by name, region, or variety", + "inputSchema" => Dict( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "maxResults" => Dict("type" => "integer", "default" => 10) + ), + "required" => ["query"] + ), + "executeCode" => """ + query = args["query"] + max_results = get(args, "maxResults", 10) + # Perform search logic here + result = "Found 3 wines matching: $query" + return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) + """, + "parallel" => false +) +``` + +3. Agent calls `listTools` to check for name collisions (built-in, auto-registered) +4. Agent calls `writeTool` with a unique name +5. Restart agent — `loadTools("src/tools")` picks up the new file +6. Agent's next LLM turn discovers and calls the new tool + +**Generated file format:** + +```julia +# Auto-generated tool: searchWine +# Generated by writeTool at 2026-08-08T14:00:00 + +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + query = args["query"] + max_results = get(args, "maxResults", 10) + result = "Found 3 wines matching: $query" + return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) +end + +function getTool()::agentTool + return agentTool( + name = "searchWine", + label = "Wine Search", + description = "Search a wine database by name, region, or variety", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "maxResults" => Dict("type" => "integer", "default" => 10) + ), + "required" => ["query"] + ), + execute = executeTool, + validateRequiredArgs = nothing, + prepareArguments = nothing, + parallelToolExecute = false + ) +end +``` + +**Optional hooks:** + +| Field | Description | +|---|---| +| `validateCode` | Custom validation Julia code (runs before execute). Return `nothing` to pass, or an error `String` to fail. | +| `prepareCode` | Argument preparation code (runs before validation). Return modified args dict. | + +### `listTools` — Discover Available Tools + +Returns a list of all registered tools with names, labels, and descriptions. + +```julia +# Result from listTools: +# Available tools: +# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city. +# - getTime: Time Lookup — Get current local time for a timezone or city. +# - writeTool: Create Tool — Generate new tool files... +# - listTools: List Tools — List all available tools with their names and labels... +``` + +### Complete Self-Tooling Example + +``` +User: "I need to search for wines. Do you have a tool for that?" + +# ─── LOOP: Agent realizes no wine search tool exists ───────────────── + +[Tool Call] listTools() +# Result: lists all available tools — no collision with existing tools + +[Tool Call] writeTool(name="searchWine", label="Wine Search", + description="Search a wine database by name, region, or variety", + inputSchema={...}, + executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") + +# writeTool generates searchWine.jl + +# ─── SYSTEM RESTARTS ───────────────────────────────────────────────── + +# Agent restarts — loadTools("src/tools") picks up searchWine.jl +# searchWine is now available — agent uses it directly + +# ─── Agent calls the new tool ───────────────────────────────────────── + +[Tool Call] searchWine(query="cabernet", maxResults=5) +# Result: "Found 5 cabernet wines..." + +# ─── Final response ────────────────────────────────────────────────── + +"The search found 5 cabernet wines: ..." +``` + ## Available Tools | Tool | Description | Validation | |---|---|---| | `getWeather` | Fetch weather for a city | Default (JSON Schema required) | | `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | +| `writeTool` | Create a new Julia tool module at runtime | Built-in (name + schema validation) | +| `listTools` | List all available tools with descriptions | None (no arguments) | diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 5ce045f..65b1085 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -1,12 +1,51 @@ module toolRegistry -export loadTools, registerTool, getTools, listTools, clearTools +export loadTools, registerTool, getTools, clearTools using ..type # Global registry — populated at runtime by loadTools() or registerTool() const _registry = Vector{agentTool}() +# Auto-register the built-in listTools tool +function __init__() + registerTool(_listTool()) +end + +""" +List tool definition — lets the agent query available tools for collision detection +when creating new tools via writeTool. +""" +function _listTool()::agentTool + return agentTool( + name = "listTools", + label = "List Tools", + description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict{String,Any}(), + "required" => Any[] + ), + execute = (toolCallId, args, signal, onPartialResult) -> begin + tools = getTools() + if isempty(tools) + result_text = "No tools registered." + else + lines = String["- $(t.name): $(t.label) — $(t.description)" for t in tools] + result_text = "Available tools:\n" * join(lines, "\n") + end + return agentToolResult( + [textContent(result_text)], + Dict{Any,Any}("count" => length(tools)), + nothing, false + ) + end, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end + """ Load all tool modules from a directory. @@ -120,16 +159,6 @@ function getTools()::Vector{agentTool} return deepcopy(_registry) end -""" -List all registered tool names and labels. - -# Returns -- `Vector{Tuple{String,String}}`: Pairs of (name, label) -""" -function listTools()::Vector{Tuple{String,String}} - return [(t.name, t.label) for t in _registry] -end - """ Clear all registered tools from the global registry. """ diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl new file mode 100644 index 0000000..a318232 --- /dev/null +++ b/src/tools/writeTool.jl @@ -0,0 +1,269 @@ +""" +Tool that generates new Julia tool module files. + +The agent can use this tool when it encounters a task that no existing tool +can handle. Provide the tool's name, label, description, inputSchema, and +execute logic as Julia code. The tool is written to `src/tools/.jl`. + +After calling this tool, restart the agent so the new tool is loaded by +`loadTools("src/tools")`. Then call `listTools` to verify the new tool +is available. + +# Example + +1. Agent calls writeTool with a spec for a "searchWine" tool +2. writeTool generates src/tools/searchWine.jl +3. Restart agent — loadTools() picks up the new file +4. Agent calls searchWine with args + +# Important Notes + +- The `executeCode` string is embedded literally into the generated tool. + Use `args["param_name"]` to access input parameters. +- The code string should be the function body (NOT wrapped in a function). + Lines will be indented with 4 spaces inside the execute function. +- Tool names must be valid Julia identifiers (lowercase letters, digits, underscores, + no leading digits or special characters). +""" + +""" +Validate that a tool name is a valid Julia identifier. +""" +function validateToolName(name::String)::Union{Nothing,String} + if !occursin(r"^[a-zA-Z_][a-zA-Z0-9_!]*$", name) + return "Invalid tool name: '$name'. Tool names must be valid Julia identifiers (letters, digits, underscores, starting with a letter or underscore)." + end + return nothing +end + +""" +Execute the writeTool. + +Generates a new .jl tool file and registers it with the tool registry. +""" +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + tool_name = get(args, "name", "")::String + tool_label = get(args, "label", tool_name)::String + tool_description = get(args, "description", "")::String + tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} + execute_code = get(args, "executeCode", "")::String + validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} + prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} + parallel = get(args, "parallel", false)::Bool + + # Validate tool name + name_err = validateToolName(tool_name) + if name_err !== nothing + return agentToolResult( + [textContent(name_err)], + Dict{Any,Any}(), nothing, false + ) + end + + # Validate required fields + if isempty(tool_name) + return agentToolResult( + [textContent("Missing required field: 'name'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(tool_description) + return agentToolResult( + [textContent("Missing required field: 'description'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(execute_code) + return agentToolResult( + [textContent("Missing required field: 'executeCode'")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Generating tool: $tool_name")) + + # Build the tool file path + script_dir = dirname(@__FILE__) + tools_dir = dirname(script_dir) + filepath = joinpath(tools_dir, "$(tool_name).jl") + + # Check for naming conflicts + if isfile(filepath) + return agentToolResult( + [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) + + # Convert schema Dict to a Julia Dict literal string + schema_literal = dict_to_julia_literal(tool_schema) + + # Build optional validation function + validate_section = if validate_code !== nothing && !isempty(validate_code) + indented = indent_code(validate_code, 4) + "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" + else + "" + end + + # Build optional prepare function + prepare_section = if prepare_code !== nothing && !isempty(prepare_code) + indented = indent_code(prepare_code, 4) + "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" + else + "" + end + + # Indent user's execute code for embedding inside execute function body + indented_exec = indent_code(execute_code, 4) + + # Escape description for Julia string literal + escaped_desc = replace(tool_description, "\\" => "\\\\") + escaped_desc = replace(escaped_desc, "\"" => "\\\"") + + # Build the complete tool file content + parts = String[] + push!(parts, "# Auto-generated tool: $tool_name\n") + push!(parts, "# Generated by writeTool at $(now())\n\n") + if !isempty(validate_section) + push!(parts, validate_section) + push!(parts, "\n") + end + if !isempty(prepare_section) + push!(parts, prepare_section) + push!(parts, "\n") + end + push!(parts, "\n") + push!(parts, "# Execute function\n") + push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") + push!(parts, "$indented_exec\n") + push!(parts, "end\n\n") + push!(parts, "# Tool definition\n") + push!(parts, "function getTool()::agentTool\n") + push!(parts, " return agentTool(\n") + push!(parts, " name = \"$(tool_name)\",\n") + push!(parts, " label = \"$(tool_label)\",\n") + push!(parts, " description = \"$(escaped_desc)\",\n") + push!(parts, " inputSchema = $schema_literal,\n") + push!(parts, " execute = executeTool,\n") + if validate_code !== nothing && !isempty(validate_code) + push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") + else + push!(parts, " validateRequiredArgs = nothing,\n") + end + if prepare_code !== nothing && !isempty(prepare_code) + push!(parts, " prepareArguments = prepareArguments,\n") + else + push!(parts, " prepareArguments = nothing,\n") + end + push!(parts, " parallelToolExecute = $parallel\n") + push!(parts, " )\n") + push!(parts, "end\n") + + tool_code = join(parts) + + # Write the file — tool is loaded on next agent restart via loadTools() + write(filepath, tool_code) + + onPartialResult(Dict("status" => "Done")) + + return agentToolResult( + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + Dict{Any,Any}( + "file" => filepath, + "name" => tool_name, + "label" => tool_label, + "description" => tool_description, + ), + nothing, false + ) +end + +""" +Indent a multi-line code string by the specified number of spaces. +""" +function indent_code(code::String, n::Int)::String + prefix = " "^n + lines = split(code, '\n') + result_lines = String[prefix * line for line in lines] + return join(result_lines, "\n") +end + +""" +Convert a Julia Dict to a valid Julia Dict{String,Any}(...) literal string. +""" +function dict_to_julia_literal(d)::String + if d isa Dict + items = String[] + for (k, v) in d + key_str = json_string(k) + val_str = value_to_julia(v) + push!(items, "$key_str => $val_str") + end + return "Dict{String,Any}(" * join(items, ", ") * ")" + else + return value_to_julia(d) + end +end + +function value_to_julia(v)::String + if v isa Dict + return dict_to_julia_literal(v) + elseif v isa Vector + items = [value_to_julia(x) for x in v] + return "[" * join(items, ", ") * "]" + elseif v isa String + escaped = replace(v, "\\" => "\\\\") + escaped = replace(escaped, "\"" => "\\\"") + return "\"$escaped\"" + elseif v isa Number + return string(v) + elseif v isa Bool + return string(v) + elseif v === nothing + return "nothing" + else + return "\"$(v)\"" + end +end + +""" +Convert any Julia value to a JSON string. +""" +function json_string(v)::String + return JSON.json(v) +end + +""" +Define and return the writeTool agentTool. +""" +function getTool()::agentTool + return agentTool( + name = "writeTool", + label = "Create Tool", + description = "Generate a new Julia tool module file at src/tools/.jl. After creation, restart the agent and call listTools to verify the new tool is loaded.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"), + "label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"), + "description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"), + "inputSchema" => Dict( + "type" => "object", + "description" => "JSON Schema describing tool parameters in MCP format" + ), + "executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."), + "validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."), + "prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."), + "parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools") + ), + "required" => ["name", "label", "description", "inputSchema", "executeCode"] + ), + execute = executeTool, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end diff --git a/src/type.jl b/src/type.jl index 60ae3c5..387d49c 100644 --- a/src/type.jl +++ b/src/type.jl @@ -9,7 +9,7 @@ # Message types userMessage, assistantMessage, toolResultMessage, # Tool types - agentTool, validateRequiredArgs + agentTool, validateRequiredArgs # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types -- 2.52.0 From 2aa0d1e9a402b88a7f9898fbeea94576955230ad Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 19:44:06 +0700 Subject: [PATCH 055/112] update --- README.md | 49 +++++++++++++--------- docs/loadtools.md | 92 ------------------------------------------ src/tools/README.md | 91 +++++++++++++++++++---------------------- src/tools/writeTool.jl | 19 ++++++--- 4 files changed, 86 insertions(+), 165 deletions(-) delete mode 100644 docs/loadtools.md diff --git a/README.md b/README.md index 4702dd2..aa9f04c 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,34 @@ # YiemAgent -## TODO -- [WORKING] build prompt() -- [ ] build agent runLoop() -- [ ] build MCP server connector -- [ ] executeplan() to execute the plan -- [ ] add comprehensive tests +Julia framework for building agents with tool use. -## Changelog +## Getting Started -### Version 0.8.0 -- Converted snake_case fields to camelCase: - - `llmModel`: `base_url` → `baseUrl`, `context_window` → `contextWindow`, `max_tokens` → `maxTokens` -- Converted PascalCase type references to camelCase: - - `AgentState` → `agentState` - - `AgentTool` → `agentTool` - - `AgentMessage` → `agentMessage` - - `PendingMessageQueue` → `pendingMessageQueue` - - `ActiveRun` → `activeRun` - - `StreamFn` → `streamFn` - - `ThinkingLevel` → `thinkingLevel` - - `ToolExecutionMode` → `toolExecutionMode` +1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` +2. Create a `yiemAgent` with `loadTools("src/tools")` +3. Call `run_agent(agent, "message")` then `take_response(agent)` + +## Architecture + +``` +src/ +├── YiemAgent.jl # Module entry point +├── type.jl # Core types (messages, tools, agent state) +├── utils.jl # Message formatting, validation +├── agentCore.jl # Agent loop, tool execution pipeline +├── api.jl # Public API (run_agent, take_response, etc.) +└── tools/ + ├── registry.jl # Tool registry (loadTools, registerTool, listTools) + ├── getWeather.jl # Weather lookup tool + ├── getTime.jl # Time lookup tool + ├── writeTool.jl # Create new tool files (self-modifying) + └── README.md # Tool development guide +``` + +## Tool Development + +See `src/tools/README.md` for: +- Tool anatomy (schema, execute, getTool) +- Validation hooks +- Agent loop lifecycle +- Self-modifying tools (`writeTool`) diff --git a/docs/loadtools.md b/docs/loadtools.md deleted file mode 100644 index 42f446c..0000000 --- a/docs/loadtools.md +++ /dev/null @@ -1,92 +0,0 @@ -# Dynamic Tool Loading - -Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory without hardcoding filenames in the main module. - -## How It Works - -1. `src/tools/registry.jl` defines a `loadTools(dir::String)` function that scans a directory for `.jl` files -2. Each tool file must define a single function: `getTool()::agentTool` -3. `loadTools()` sorts files alphabetically, includes each one, calls `getTool()`, and registers the result -4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent` - -## Directory Structure - -``` -src/ -├── tools/ -│ ├── registry.jl # Tool loader (do not edit) -│ ├── getWeather.jl # Your tool -│ └── query_db.jl # Another tool -├── type.jl -├── utils.jl -├── agentCore.jl -├── api.jl -└── YiemAgent.jl -``` - -## Creating a Tool - -Each `.jl` file in `src/tools/` must define `getTool()` returning an `agentTool`: - -```julia -# src/tools/getWeather.jl -function getTool()::agentTool - return agentTool( - name = "getWeather", - label = "Weather Lookup", - description = "Fetch current weather and forecast for a given city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City and country"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") - ), - "required" => ["city"] - ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - city = args["city"] - return agentToolResult( - [textContent("Weather in $(city): Sunny, 22C")], - Dict{Any,Any}(), nothing, false - ) - end, - prepareArguments = nothing, - parallelToolExecute = false - ) -end -``` - -No `module` wrapper needed — the registry includes each file in the current module scope so all types (`agentTool`, `textContent`, `agentToolResult`, etc.) resolve correctly. - -## Loading Tools - -```julia -using .YiemAgent -using .YiemAgent: toolRegistry - -# Load all tool files from src/tools/ -tools = YiemAgent.loadTools(joinpath(@__DIR__, "src", "tools")) - -# Create agent with loaded tools -agent = yiemAgent( - systemPrompt = "You are a helpful assistant.", - model = my_model, - tools = tools, - llmCall = my_llm_call, - agentEventSink = my_event_sink -) -``` - -## Available Functions - -| Function | Description | -|----------|-------------| -| `loadTools(dir::String)` | Scan directory and load all `.jl` tool files | -| `registerTool(tool::agentTool)` | Register a single tool into the global registry | -| `getTools()` | Get deep copy of all registered tools | -| `listTools()` | List all registered tools as `(name, label)` pairs | -| `clearTools()` | Clear the global registry | - -## File Loading Order - -Files are sorted alphabetically before loading, so `01_database.jl` loads before `02_weather.jl`. This ensures deterministic registration order. diff --git a/src/tools/README.md b/src/tools/README.md index 9b7df7a..9e0c73c 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -399,15 +399,49 @@ The framework includes tools that allow the agent to create new tools at runtime ### `writeTool` — Create New Tool Files -The `writeTool` tool generates a new Julia tool file at `src/tools/.jl`. The agent writes the tool, then the agent (or system) restarts so `loadTools("src/tools")` picks it up. +`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (the actual Julia code), and `writeTool` wraps it in the required boilerplate. + +**How it works:** + +The LLM constructs `writeTool` with: +- **`executeCode`** — the actual tool logic (Julia code body, NOT wrapped in a function) +- **`name`, `label`, `description`** — tool metadata +- **`inputSchema`** — parameter schema in MCP format +- **`validateCode`, `prepareCode`** (optional) — custom validation/preparation logic + +`writeTool` produces `src/tools/.jl` by: +1. Converting the `inputSchema` Dict into a Julia `Dict{String,Any}(...)` string literal +2. Indenting `executeCode` with 4 spaces +3. Wrapping it inside a `function executeTool(...)::agentToolResult ... end` template +4. Appending the `getTool()` definition that returns an `agentTool` struct +5. Writing the combined string to disk **Workflow:** -1. Agent identifies a task that no existing tool can handle -2. Agent calls `writeTool` with a tool specification: +``` +LLM decides: "Need a searchWine tool. I'll provide the logic." + +LLM calls writeTool: + name: "searchWine" + executeCode: "query = args[\"query\"]\nresult = search(query)\nreturn ..." + +writeTool wraps it → src/tools/searchWine.jl: + function executeTool(...)::agentToolResult + query = args["query"] ← LLM code (indented 4 spaces) + result = search(query) + return agentToolResult(...) + end + + function getTool()::agentTool + return agentTool(name="searchWine", ...) + end + +Restart → loadTools("src/tools") loads searchWine.jl +``` + +**Example specification:** ```julia -# Agent sends this to writeTool: Dict( "name" => "searchWine", "label" => "Wine Search", @@ -431,45 +465,6 @@ Dict( ) ``` -3. Agent calls `listTools` to check for name collisions (built-in, auto-registered) -4. Agent calls `writeTool` with a unique name -5. Restart agent — `loadTools("src/tools")` picks up the new file -6. Agent's next LLM turn discovers and calls the new tool - -**Generated file format:** - -```julia -# Auto-generated tool: searchWine -# Generated by writeTool at 2026-08-08T14:00:00 - -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - query = args["query"] - max_results = get(args, "maxResults", 10) - result = "Found 3 wines matching: $query" - return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) -end - -function getTool()::agentTool - return agentTool( - name = "searchWine", - label = "Wine Search", - description = "Search a wine database by name, region, or variety", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string", "description" => "Search query"), - "maxResults" => Dict("type" => "integer", "default" => 10) - ), - "required" => ["query"] - ), - execute = executeTool, - validateRequiredArgs = nothing, - prepareArguments = nothing, - parallelToolExecute = false - ) -end -``` - **Optional hooks:** | Field | Description | @@ -479,7 +474,7 @@ end ### `listTools` — Discover Available Tools -Returns a list of all registered tools with names, labels, and descriptions. +Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool` — the LLM checks existing names before picking a unique one. ```julia # Result from listTools: @@ -497,20 +492,18 @@ User: "I need to search for wines. Do you have a tool for that?" # ─── LOOP: Agent realizes no wine search tool exists ───────────────── -[Tool Call] listTools() -# Result: lists all available tools — no collision with existing tools +# LLM generates the tool logic and calls writeTool to write it to disk [Tool Call] writeTool(name="searchWine", label="Wine Search", description="Search a wine database by name, region, or variety", inputSchema={...}, executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") -# writeTool generates searchWine.jl +# writeTool generates src/tools/searchWine.jl # ─── SYSTEM RESTARTS ───────────────────────────────────────────────── -# Agent restarts — loadTools("src/tools") picks up searchWine.jl -# searchWine is now available — agent uses it directly +# loadTools("src/tools") loads searchWine.jl alongside all other tools # ─── Agent calls the new tool ───────────────────────────────────────── diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index a318232..fb2b969 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -1,13 +1,12 @@ """ -Tool that generates new Julia tool module files. +Tool that writes new Julia tool module files to disk. The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so the new tool is loaded by -`loadTools("src/tools")`. Then call `listTools` to verify the new tool -is available. +After calling this tool, restart the agent so `loadTools("src/tools")` picks +up the new file. The new tool is immediately available. # Example @@ -16,6 +15,16 @@ is available. 3. Restart agent — loadTools() picks up the new file 4. Agent calls searchWine with args +# How It Works + +writeTool is a **file writer**, not a code generator. The LLM provides the +tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: + - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string + - Indents `executeCode` with 4 spaces + - Wraps it inside `function executeTool(...)::agentToolResult ... end` + - Appends `getTool()` returning an `agentTool` struct + - Writes the combined string to `src/tools/.jl` + # Important Notes - The `executeCode` string is embedded literally into the generated tool. @@ -243,7 +252,7 @@ function getTool()::agentTool return agentTool( name = "writeTool", label = "Create Tool", - description = "Generate a new Julia tool module file at src/tools/.jl. After creation, restart the agent and call listTools to verify the new tool is loaded.", + description = "Write a new Julia tool module file to src/tools/.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.", inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict( -- 2.52.0 From 069240912b2f00d330026f9924699a2e84a4fd53 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 04:45:31 +0700 Subject: [PATCH 056/112] update --- test/Manifest.toml | 41 ----------------------------------------- test/Project.toml | 2 -- 2 files changed, 43 deletions(-) delete mode 100644 test/Manifest.toml delete mode 100644 test/Project.toml diff --git a/test/Manifest.toml b/test/Manifest.toml deleted file mode 100644 index 83f035b..0000000 --- a/test/Manifest.toml +++ /dev/null @@ -1,41 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -julia_version = "1.11.4" -manifest_format = "2.0" -project_hash = "71d91126b5a1fb1020e1098d9d492de2a4438fd2" - -[[deps.Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" -version = "1.11.0" - -[[deps.InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" -version = "1.11.0" - -[[deps.Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" -version = "1.11.0" - -[[deps.Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" -version = "1.11.0" - -[[deps.Random]] -deps = ["SHA"] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -version = "1.11.0" - -[[deps.SHA]] -uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" -version = "0.7.0" - -[[deps.Serialization]] -uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" -version = "1.11.0" - -[[deps.Test]] -deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] -uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -version = "1.11.0" diff --git a/test/Project.toml b/test/Project.toml deleted file mode 100644 index 0c36332..0000000 --- a/test/Project.toml +++ /dev/null @@ -1,2 +0,0 @@ -[deps] -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -- 2.52.0 From da16c80a0ab1f2465f2aeef6971ce2ba9bf5a2be Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 06:27:48 +0700 Subject: [PATCH 057/112] update --- Manifest.toml | 152 +++++++++++++++------------------------ Project.toml | 2 - src/agentCore.jl | 2 +- src/api.jl | 2 +- src/tools/registry.jl | 2 +- src/type.jl | 4 +- src_OLD/OLD_interface.jl | 2 +- src_OLD/llmfunction.jl | 2 +- test/loadToolTest.jl | 1 + 9 files changed, 66 insertions(+), 103 deletions(-) create mode 100644 test/loadToolTest.jl diff --git a/Manifest.toml b/Manifest.toml index 6fe654f..2e462e1 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "1c1379a2cec320abc347f3acb5ee815ba9855aa6" +project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -97,9 +97,9 @@ uuid = "944b1d66-785c-5afd-91f1-9de20f533193" version = "0.7.8" [[deps.CommonSolve]] -git-tree-sha1 = "eeaad7cef88554c2fa56b5a3f71cfd5cb708c662" +git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" -version = "0.2.11" +version = "0.2.13" [[deps.Compat]] deps = ["TOML", "UUIDs"] @@ -146,9 +146,9 @@ version = "1.6.0" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" [[deps.Crayons]] -git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" +git-tree-sha1 = "54b76cbb40d9a0f5368c880725b2f141da77c94f" uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" -version = "4.1.1" +version = "4.2.0" [[deps.DBInterface]] git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b" @@ -168,9 +168,9 @@ version = "1.8.2" [[deps.DataStructures]] deps = ["OrderedCollections"] -git-tree-sha1 = "6fb53a69613a0b2b68a0d12671717d307ab8b24e" +git-tree-sha1 = "b0bc6d2cad1fed8b7fd59a1551a991cb3d2809e6" uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -version = "0.19.5" +version = "0.19.6" [[deps.DataValueInterfaces]] git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" @@ -208,9 +208,9 @@ version = "1.11.0" [[deps.Distributions]] deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] -git-tree-sha1 = "cd3c5ac74cd3923c8945c6a81518c46abd0e73a3" +git-tree-sha1 = "d2facc77c08c1c2bfb1a77c148edd05b3db5410b" uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" -version = "0.25.129" +version = "0.25.130" [deps.Distributions.extensions] DistributionsChainRulesCoreExt = "ChainRulesCore" @@ -240,15 +240,9 @@ uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" version = "1.0.7" [[deps.ExprTools]] -git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec" +git-tree-sha1 = "d2e49e7efd29719d6f28b891b0e0e159daa9d2b4" uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" -version = "0.1.10" - -[[deps.EzXML]] -deps = ["Printf", "XML2_jll"] -git-tree-sha1 = "7ea1aa5869e2626ccae84480e4f37185bc6f41d3" -uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615" -version = "1.2.3" +version = "0.1.11" [[deps.FileIO]] deps = ["Pkg", "Requires", "UUIDs"] @@ -277,9 +271,9 @@ version = "1.11.0" [[deps.FillArrays]] deps = ["LinearAlgebra"] -git-tree-sha1 = "2f979084d1e13948a3352cf64a25df6bd3b4dca3" +git-tree-sha1 = "5bad39456d9f0166184fce2248783dd9862645c1" uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" -version = "1.16.0" +version = "1.17.0" weakdeps = ["PDMats", "SparseArrays", "StaticArrays", "Statistics"] [deps.FillArrays.extensions] @@ -300,11 +294,11 @@ version = "1.1.0" [[deps.GeneralUtils]] deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"] -git-tree-sha1 = "93293126d24d3929ef6a5067f347bc28c6582c71" +git-tree-sha1 = "129b8fa1bf3bf6d8c0a080f39db09b9b986ef8de" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.10" +version = "0.5.11" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] @@ -321,9 +315,9 @@ version = "1.14.0" [[deps.HTTP]] deps = ["Base64", "CodecZlib", "Dates", "EnumX", "PrecompileTools", "Random", "Reseau", "SHA", "URIs", "UUIDs", "Zlib_jll"] -git-tree-sha1 = "c2c808326222b6dc4bec295a83b55f79aeec98e0" +git-tree-sha1 = "0a58fbbdee93d132a2fb1159b7f5e1b5c2465e71" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "2.5.5" +version = "2.6.4" [[deps.HashArrayMappedTries]] git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" @@ -332,9 +326,9 @@ version = "0.2.0" [[deps.HypergeometricFunctions]] deps = ["Gamma", "LinearAlgebra"] -git-tree-sha1 = "18d7deab5fb0440dc6a7b6993c5c27b25420de10" +git-tree-sha1 = "31bb6c92405c084617facc1d7ed9eb6c402d061e" uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" -version = "0.3.29" +version = "0.3.30" [[deps.ICU_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] @@ -412,9 +406,9 @@ version = "1.8.0" [[deps.JSON]] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] -git-tree-sha1 = "c89d196f5ffb64bfbf80985b699ea913b0d2c211" +git-tree-sha1 = "65979512c25a0727f050e6e4be40f0fd9ec893f7" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "1.6.1" +version = "1.7.0" weakdeps = ["ArrowTypes"] [deps.JSON.extensions] @@ -432,9 +426,9 @@ weakdeps = ["ArrowTypes"] [[deps.JuliaInterpreter]] deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] -git-tree-sha1 = "58927c485919bf17ea308d9d82156de1adf4b006" +git-tree-sha1 = "c3d401f110454b4ea24a76be33f6ee0d7d385103" uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" -version = "0.10.12" +version = "0.11.4" [[deps.JuliaSyntaxHighlighting]] deps = ["StyledStrings"] @@ -506,12 +500,6 @@ version = "1.11.3+1" uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" version = "1.11.0" -[[deps.Libiconv_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl"] -git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" -uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" -version = "1.18.0+0" - [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -539,9 +527,9 @@ version = "1.11.0" [[deps.LoweredCodeUtils]] deps = ["CodeTracking", "Compiler", "JuliaInterpreter"] -git-tree-sha1 = "3733419e9a71156b389f3e331672d2e95436783f" +git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" -version = "3.6.2" +version = "3.8.0" [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" @@ -599,9 +587,9 @@ version = "0.1.1" [[deps.NanoDates]] deps = ["Dates", "Parsers"] -git-tree-sha1 = "850a0557ae5934f6e67ac0dc5ca13d0328422d1f" +git-tree-sha1 = "77c7e98ca39aefb481f9b97a2f4f5c5471c08a1d" uuid = "46f1a544-deae-4307-8689-c12aa3c955c6" -version = "1.0.3" +version = "1.1.0" [[deps.NetworkOptions]] uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" @@ -640,15 +628,15 @@ uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" version = "0.5.6+0" [[deps.OrderedCollections]] -git-tree-sha1 = "94ba93778373a53bfd5a0caaf7d809c445292ff4" +git-tree-sha1 = "05f45c2e0de6259db764adbfd2f1dc6d3f8de13c" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.8.2" +version = "2.0.1" [[deps.PDMats]] deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] -git-tree-sha1 = "26766d4b5f1a410c218a19b85a672c6edb693c65" +git-tree-sha1 = "123266c25174ef6c8d4718920abc206452cf8de6" uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" -version = "0.11.40" +version = "0.11.41" weakdeps = ["StatsBase"] [deps.PDMats.extensions] @@ -656,9 +644,9 @@ weakdeps = ["StatsBase"] [[deps.Parsers]] deps = ["Dates", "PrecompileTools", "UUIDs"] -git-tree-sha1 = "32a4e09c5f29402573d673901778a0e03b0807b9" +git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" -version = "2.8.6" +version = "2.8.7" [[deps.Pkg]] deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] @@ -688,15 +676,15 @@ uuid = "21216c6a-2e73-6563-6e65-726566657250" version = "1.5.2" [[deps.PrettyPrinting]] -git-tree-sha1 = "142ee93724a9c5d04d78df7006670a93ed1b244e" +git-tree-sha1 = "0b7f4ad437e31c51cf5b91fb103579b04025170a" uuid = "54e16d92-306c-5ea0-a30b-337be88ac337" -version = "0.4.2" +version = "0.4.3" [[deps.PrettyTables]] deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"] -git-tree-sha1 = "ebf455bb866ee6737030e3d3816bb6a0683c4325" +git-tree-sha1 = "4ac881f5432bd93463a41767a814a45245be22b6" uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -version = "3.4.0" +version = "3.4.6" [deps.PrettyTables.extensions] PrettyTablesExcelExt = "XLSX" @@ -757,15 +745,15 @@ version = "1.3.1" [[deps.Reseau]] deps = ["NetworkOptions", "OpenSSL_jll", "PrecompileTools", "Random", "SHA"] -git-tree-sha1 = "0eab6d95ed40c2ef3992255c1c71e4f9748932b5" +git-tree-sha1 = "701ef63506992668c0069a4d1ac1540b77258e6c" uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36" -version = "1.3.1" +version = "1.3.6" [[deps.Revise]] -deps = ["CRC32c", "CodeTracking", "FileWatching", "InteractiveUtils", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] -git-tree-sha1 = "27e3ee13fc8739a59b380d6163d6a82f52c03bd7" +deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] +git-tree-sha1 = "ec46aed6a3a8cc6b67839ca361e7b4aa32eaeee1" uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" -version = "3.15.1" +version = "3.16.3" weakdeps = ["Distributed"] [deps.Revise.extensions] @@ -779,15 +767,15 @@ version = "0.9.0" [[deps.Rmath_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] -git-tree-sha1 = "58cdd8fb2201a6267e1db87ff148dd6c1dbd8ad8" +git-tree-sha1 = "6d40b2fe70437b01397d2a4d5b020008da4e7019" uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" -version = "0.5.1+0" +version = "0.5.2+0" [[deps.Roots]] deps = ["Accessors", "CommonSolve", "Printf"] -git-tree-sha1 = "a7caaf7ba8cf307112ca443784d1b56b4a591455" +git-tree-sha1 = "7fb25a964849d90a0446366cdefca822e0e84900" uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" -version = "3.0.5" +version = "3.0.6" [deps.Roots.extensions] RootsChainRulesCoreExt = "ChainRulesCore" @@ -840,12 +828,6 @@ git-tree-sha1 = "084c47c7c5ce5cfecefa0a98dff69eb3646b5a80" uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" version = "1.4.10" -[[deps.Serde]] -deps = ["CSV", "Dates", "EzXML", "JSON", "TOML", "UUIDs", "YAML"] -git-tree-sha1 = "f397fc8779cc53e4677c2708f3802c6996f28d00" -uuid = "db9b398d-9517-45f8-9a95-92af99003e0e" -version = "3.7.2" - [[deps.Serialization]] uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" version = "1.11.0" @@ -879,9 +861,9 @@ version = "1.12.0" [[deps.SpecialFunctions]] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] -git-tree-sha1 = "6547cbdd8ce32efba0d21c5a40fa96d1a3548f9f" +git-tree-sha1 = "c3ac026e735264e9bdc6a9bcbd1b1e781b36e3bc" uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "2.8.0" +version = "2.8.3" [deps.SpecialFunctions.extensions] SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" @@ -932,9 +914,9 @@ version = "0.34.12" [[deps.StatsFuns]] deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] -git-tree-sha1 = "770240df9a3b8888065046948f7a09b4e0f997d5" +git-tree-sha1 = "91a5737baed20ee31f3faea0e51f57461f6a689e" uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" -version = "2.2.0" +version = "2.2.1" [deps.StatsFuns.extensions] StatsFunsChainRulesCoreExt = "ChainRulesCore" @@ -950,17 +932,11 @@ git-tree-sha1 = "cd83a04baf746e3b43b83c61b7de77ab0409b80a" uuid = "88034a9c-02f8-509d-84a9-84ec65e18404" version = "1.0.0" -[[deps.StringEncodings]] -deps = ["Libiconv_jll"] -git-tree-sha1 = "b765e46ba27ecf6b44faf70df40c57aa3a547dcb" -uuid = "69024149-9ee7-55f6-a4c4-859efe599b68" -version = "0.3.7" - [[deps.StringManipulation]] deps = ["PrecompileTools"] -git-tree-sha1 = "d05693d339e37d6ab134c5ab53c29fce5ee5d7d5" +git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" -version = "0.4.4" +version = "0.4.7" [[deps.StructTypes]] deps = ["Dates", "UUIDs"] @@ -970,9 +946,9 @@ version = "1.11.0" [[deps.StructUtils]] deps = ["Dates", "UUIDs"] -git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" +git-tree-sha1 = "c65ae4aa47e543c278aea0a3468786d33021a3ff" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" -version = "2.8.2" +version = "2.8.4" [deps.StructUtils.extensions] StructUtilsMeasurementsExt = ["Measurements"] @@ -1046,9 +1022,9 @@ uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" version = "0.11.3" [[deps.URIs]] -git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +git-tree-sha1 = "3b0738bd7c5645641845da25cbd99800b8718689" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" -version = "1.6.1" +version = "1.6.2" [[deps.UTCDateTimes]] deps = ["Dates", "TimeZones"] @@ -1076,23 +1052,11 @@ git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" version = "1.6.1" -[[deps.XML2_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Libiconv_jll", "Zlib_jll"] -git-tree-sha1 = "3f3315d89fc954a28f5b471bce698ed6e27481be" -uuid = "02c8fc9c-b97f-50b9-bbe4-9be30ff0a78a" -version = "2.15.3+0" - -[[deps.YAML]] -deps = ["Base64", "Dates", "Printf", "StringEncodings"] -git-tree-sha1 = "a1c0c7585346251353cddede21f180b96388c403" -uuid = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" -version = "0.4.16" - [[deps.YiemAgent]] -deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serde", "Serialization", "URIs", "UUIDs"] +deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serialization", "URIs", "UUIDs"] path = "." uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" -version = "0.7.4" +version = "0.8.0" [[deps.Zlib_jll]] deps = ["Libdl"] diff --git a/Project.toml b/Project.toml index 4a4df5f..f687431 100644 --- a/Project.toml +++ b/Project.toml @@ -19,7 +19,6 @@ PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" -Serde = "db9b398d-9517-45f8-9a95-92af99003e0e" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" @@ -34,4 +33,3 @@ JSON = "1.6.1" LLMMCTS = "0.1.5" NATS = "0.1.0" SQLLLM = "0.2.8" -Serde = "3.7.2" diff --git a/src/agentCore.jl b/src/agentCore.jl index 0ee5916..852d145 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -3,7 +3,7 @@ module agentCore export _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde, Base.Threads + DataFrames, Base.Threads using GeneralUtils using ..type, ..utils diff --git a/src/api.jl b/src/api.jl index ec8ece2..b126b19 100644 --- a/src/api.jl +++ b/src/api.jl @@ -3,7 +3,7 @@ module api export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde + DataFrames using GeneralUtils using ..type, ..utils diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 65b1085..e7d691d 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -74,7 +74,7 @@ function getTool()::agentTool execute = (toolCallId, args, signal, onPartialResult) -> begin city = args["city"] return agentToolResult( - [textContent("Sunny, 22C in $(city)")], + [textContent("Sunny, 22C in Bangkok")], Dict{Any,Any}(), nothing, false ) end, diff --git a/src/type.jl b/src/type.jl index 387d49c..33597df 100644 --- a/src/type.jl +++ b/src/type.jl @@ -9,7 +9,7 @@ # Message types userMessage, assistantMessage, toolResultMessage, # Tool types - agentTool, validateRequiredArgs + agentTool, validateRequiredArgs, # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types @@ -248,7 +248,7 @@ tool = agentTool( execute=(toolCallId, args, signal, onPartialResult) -> begin city = args["city"] return agentToolResult( - [textContent("Sunny, 22C in $(city)")], + [textContent("Sunny, 22C in Bangkok")], Dict{Any,Any}(), nothing, false ) end, diff --git a/src_OLD/OLD_interface.jl b/src_OLD/OLD_interface.jl index 5e375d6..fc74a9f 100644 --- a/src_OLD/OLD_interface.jl +++ b/src_OLD/OLD_interface.jl @@ -4,7 +4,7 @@ export addNewMessage, conversation, decisionMaker, reflector, generatechat, generalconversation, detectWineryName, generateSituationReport using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde + DataFrames using GeneralUtils using ..type, ..util, ..llmfunction diff --git a/src_OLD/llmfunction.jl b/src_OLD/llmfunction.jl index 2cc1b3d..e7c8903 100644 --- a/src_OLD/llmfunction.jl +++ b/src_OLD/llmfunction.jl @@ -5,7 +5,7 @@ export virtualWineUserChatbox, jsoncorrection, search_wine_database!, # recomme extractWineAttributes_2, paraphrase, SQLexecution using HTTP, JSON, URIs, Random, PrettyPrinting, UUIDs, Dates, DataFrames, DataStructures, - Base64, Serde, LibPQ, NATS + Base64, LibPQ, NATS using GeneralUtils, SQLLLM using ..type, ..util diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl new file mode 100644 index 0000000..071a6ec --- /dev/null +++ b/test/loadToolTest.jl @@ -0,0 +1 @@ +using YiemAgent \ No newline at end of file -- 2.52.0 From 6b3d575ea07cf8e94a396d86dc28122adb56e149 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 06:50:37 +0700 Subject: [PATCH 058/112] update --- src/tools/registry.jl | 3 +- src/type.jl | 11 +++ test/loadToolTest.jl | 180 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/src/tools/registry.jl b/src/tools/registry.jl index e7d691d..b828793 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -119,7 +119,8 @@ function loadTools(dir::String)::Vector{agentTool} end # Call getTool() — it runs in current scope where types are visible - tool = getTool() + # Use invokelatest to handle world-age semantics after include() + tool = invokelatest(getTool) if !(tool isa agentTool) throw(ArgumentError( "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" diff --git a/src/type.jl b/src/type.jl index 33597df..7078168 100644 --- a/src/type.jl +++ b/src/type.jl @@ -269,6 +269,17 @@ struct agentTool # A tool available to the agent parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel end +""" +Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. +""" +function agentTool(; name::String, label::String, description::String, inputSchema::Any, + execute::Function, prepareArguments::Union{Function, Nothing}=nothing, + validateRequiredArgs::Union{Function, Nothing}=nothing, + parallelToolExecute::Bool=false) + return agentTool(name, label, description, inputSchema, execute, + prepareArguments, validateRequiredArgs, parallelToolExecute) +end + # ------------------------------------------------------------------------------------------------ # # Agent context # diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index 071a6ec..d228efe 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -1 +1,179 @@ -using YiemAgent \ No newline at end of file +using Test +using YiemAgent +using YiemAgent.toolRegistry +using YiemAgent.type + +# ------------------------------------------------------------------ # +# loadTools() unit tests # +# ------------------------------------------------------------------ # + +@testset "loadTools" begin + + # ------------------------------------------------------------------ # + # 1. loadTools throws on non-existent directory # + # ------------------------------------------------------------------ # + @test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist") + + # ------------------------------------------------------------------ # + # 2. loadTools throws if a .jl file does not define getTool() # + # Must run before any other loadTools call (getTool persists in # + # module scope after include()). # + # ------------------------------------------------------------------ # + bad_dir = mktempdir() + write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") + @test_throws ArgumentError loadTools(bad_dir) + + # ------------------------------------------------------------------ # + # 3. loadTools loads tool files that define getTool() # + # ------------------------------------------------------------------ # + tmpdir = mktempdir() + + # Create a valid tool file (must use bare type names — include() places file in toolRegistry scope) + valid_tool_echo = """ +function getTool()::agentTool + return agentTool( + name = "testEcho", + label = "Echo Test", + description = "Echoes the input argument", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict("message" => Dict("type" => "string")), + "required" => Any["message"] + ), + execute = (toolCallId, args, signal, onPartialResult) -> begin + return agentToolResult( + [textContent("echo: " * string(args["message"]))], + Dict{Any,Any}(), nothing, false + ) + end, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + write(joinpath(tmpdir, "getEcho.jl"), valid_tool_echo) + + loaded = loadTools(tmpdir) + @test !isempty(loaded) + @test length(loaded) >= 1 + + names = [t.name for t in loaded] + @test "testEcho" in names + + # Check agentTool fields + echo_tool = filter(t -> t.name == "testEcho", loaded) + @test !isempty(echo_tool) + @test echo_tool[1].label == "Echo Test" + @test echo_tool[1].description == "Echoes the input argument" + @test echo_tool[1].parallelToolExecute == false + @test echo_tool[1].execute !== nothing + + # ------------------------------------------------------------------ # + # 4. loadTools returns tools sorted alphabetically # + # ------------------------------------------------------------------ # + sorted_dir = mktempdir() + + tool_a = """ +function getTool()::agentTool + return agentTool( + name = "alphaTool", + label = "Alpha Tool", + description = "First tool", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("alpha")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + + tool_m = """ +function getTool()::agentTool + return agentTool( + name = "midTool", + label = "Mid Tool", + description = "Middle tool", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("mid")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + + tool_z = """ +function getTool()::agentTool + return agentTool( + name = "zuluTool", + label = "Zulu Tool", + description = "Last tool", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("zulu")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + + write(joinpath(sorted_dir, "zTool.jl"), tool_z) + write(joinpath(sorted_dir, "aTool.jl"), tool_a) + write(joinpath(sorted_dir, "mTool.jl"), tool_m) + + loaded_sorted = loadTools(sorted_dir) + # loadTools returns only tools loaded from the directory, in file-sorted order + @test length(loaded_sorted) == 3 + @test loaded_sorted[1].name == "alphaTool" + @test loaded_sorted[2].name == "midTool" + @test loaded_sorted[3].name == "zuluTool" + + # ------------------------------------------------------------------ # + # 5. getTools returns a deep copy (mutations don't affect registry) # + # ------------------------------------------------------------------ # + registry_tools = getTools() + @test !isempty(registry_tools) + orig_count = length(registry_tools) + + # Clear and add a new tool via registerTool + clearTools() + registry_after_clear = getTools() + @test isempty(registry_after_clear) + + # ------------------------------------------------------------------ # + # 6. registerTool adds to global registry # + # ------------------------------------------------------------------ # + test_tool = agentTool( + name = "manualTool", + label = "Manual Tool", + description = "Registered manually", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = true + ) + registerTool(test_tool) + reg = getTools() + @test any(t -> t.name == "manualTool", reg) + @test count(t -> t.name == "manualTool", reg) == 1 + + # parallelToolExecute flag + manual_entry = filter(t -> t.name == "manualTool", reg) + @test manual_entry[1].parallelToolExecute == true + + # ------------------------------------------------------------------ # + # 7. getTools returns deep copy # + # ------------------------------------------------------------------ # + copy1 = getTools() + copy2 = getTools() + @test copy1 !== copy2 + empty!(copy1) + @test !isempty(getTools()) +end -- 2.52.0 From ed5415d92a41568eb9c1a6662bf9b66a2287b995 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 08:45:35 +0700 Subject: [PATCH 059/112] update --- src/tools/getTime.jl | 47 +++---- src/tools/getWeather.jl | 41 ++---- src/tools/registry.jl | 4 +- src/tools/writeTool.jl | 285 ++++++++++++++++++++-------------------- test/loadToolTest.jl | 195 +++++++++++---------------- 5 files changed, 243 insertions(+), 329 deletions(-) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 97636ab..0d6a467 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -36,35 +36,6 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end -""" -Execute the getTime tool. - -# Arguments -- `toolCallId::String`: Unique identifier for this tool call -- `args::Dict{String,Any}`: Parsed arguments from the LLM -- `signal::Union{Nothing,abortSignal}`: Optional abort signal -- `onPartialResult::Function`: Callback for streaming partial results - -# Returns -- `agentToolResult`: Result content with current time data -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - # Simulate time lookup — replace with actual timezone API call - if tz !== nothing - result = "Current time in $(tz): $(now())" - else - result = "Current time in $(city): $(now())" - end - - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), nothing, false - ) -end - """ Define and return the getTime agentTool. """ @@ -76,12 +47,24 @@ function getTool()::agentTool inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict( - "timezone" => Dict("type" => "string", "description", "IANA timezone, e.g. 'America/New_York'"), - "city" => Dict("type" => "string", "description", "City name as fallback") + "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"), + "city" => Dict("type" => "string", "description" => "City name as fallback") ), "required" => [] ), - execute = executeTool, + execute = (toolCallId, args, signal, onPartialResult) -> begin + tz = get(args, "timezone", nothing) + city = get(args, "city", "") + if tz !== nothing + result = "Current time in $(tz): $(now())" + else + result = "Current time in $(city): $(now())" + end + return agentToolResult( + [textContent(result)], + Dict{Any,Any}(), nothing, false + ) + end, prepareArguments = nothing, validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 0411841..61a17f5 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,33 +1,3 @@ -""" -Execute the getWeather tool. - -# Arguments -- `toolCallId::String`: Unique identifier for this tool call -- `args::Dict{String,Any}`: Parsed arguments from the LLM -- `signal::Union{Nothing,abortSignal}`: Optional abort signal -- `onPartialResult::Function`: Callback for streaming partial results - -# Returns -- `agentToolResult`: Result content with weather data -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - city = get(args, "city", "") - units = get(args, "units", "celsius") - - # Simulate weather fetch — replace with actual API call - # You can call onPartialResult() here for streaming progress updates: - # onPartialResult(Dict("status" => "Fetching weather data...")) - # onPartialResult(Dict("status" => "Processing...")) - - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), nothing, false - ) -end - """ Define and return the getWeather agentTool. """ @@ -44,7 +14,16 @@ function getTool()::agentTool ), "required" => ["city"] ), - execute = executeTool, # reference the function defined above + execute = (toolCallId, args, signal, onPartialResult) -> begin + city = get(args, "city", "") + units = get(args, "units", "celsius") + temp = units == "fahrenheit" ? "72" : "22" + unit_symbol = units == "celsius" ? "°C" : "°F" + return agentToolResult( + [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], + Dict{Any,Any}(), nothing, false + ) + end, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/src/tools/registry.jl b/src/tools/registry.jl index b828793..f7aa585 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -2,6 +2,8 @@ module toolRegistry export loadTools, registerTool, getTools, clearTools +using Dates +using JSON using ..type # Global registry — populated at runtime by loadTools() or registerTool() @@ -99,7 +101,7 @@ function loadTools(dir::String)::Vector{agentTool} end tools = agentTool[] - jl_files = filter(f -> endswith(f, ".jl"), readdir(dir)) + jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) sort!(jl_files) for filename in jl_files diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index fb2b969..40c4d8c 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -45,151 +45,6 @@ function validateToolName(name::String)::Union{Nothing,String} return nothing end -""" -Execute the writeTool. - -Generates a new .jl tool file and registers it with the tool registry. -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - tool_name = get(args, "name", "")::String - tool_label = get(args, "label", tool_name)::String - tool_description = get(args, "description", "")::String - tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} - execute_code = get(args, "executeCode", "")::String - validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} - prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} - parallel = get(args, "parallel", false)::Bool - - # Validate tool name - name_err = validateToolName(tool_name) - if name_err !== nothing - return agentToolResult( - [textContent(name_err)], - Dict{Any,Any}(), nothing, false - ) - end - - # Validate required fields - if isempty(tool_name) - return agentToolResult( - [textContent("Missing required field: 'name'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(tool_description) - return agentToolResult( - [textContent("Missing required field: 'description'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(execute_code) - return agentToolResult( - [textContent("Missing required field: 'executeCode'")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Generating tool: $tool_name")) - - # Build the tool file path - script_dir = dirname(@__FILE__) - tools_dir = dirname(script_dir) - filepath = joinpath(tools_dir, "$(tool_name).jl") - - # Check for naming conflicts - if isfile(filepath) - return agentToolResult( - [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) - - # Convert schema Dict to a Julia Dict literal string - schema_literal = dict_to_julia_literal(tool_schema) - - # Build optional validation function - validate_section = if validate_code !== nothing && !isempty(validate_code) - indented = indent_code(validate_code, 4) - "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" - else - "" - end - - # Build optional prepare function - prepare_section = if prepare_code !== nothing && !isempty(prepare_code) - indented = indent_code(prepare_code, 4) - "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" - else - "" - end - - # Indent user's execute code for embedding inside execute function body - indented_exec = indent_code(execute_code, 4) - - # Escape description for Julia string literal - escaped_desc = replace(tool_description, "\\" => "\\\\") - escaped_desc = replace(escaped_desc, "\"" => "\\\"") - - # Build the complete tool file content - parts = String[] - push!(parts, "# Auto-generated tool: $tool_name\n") - push!(parts, "# Generated by writeTool at $(now())\n\n") - if !isempty(validate_section) - push!(parts, validate_section) - push!(parts, "\n") - end - if !isempty(prepare_section) - push!(parts, prepare_section) - push!(parts, "\n") - end - push!(parts, "\n") - push!(parts, "# Execute function\n") - push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") - push!(parts, "$indented_exec\n") - push!(parts, "end\n\n") - push!(parts, "# Tool definition\n") - push!(parts, "function getTool()::agentTool\n") - push!(parts, " return agentTool(\n") - push!(parts, " name = \"$(tool_name)\",\n") - push!(parts, " label = \"$(tool_label)\",\n") - push!(parts, " description = \"$(escaped_desc)\",\n") - push!(parts, " inputSchema = $schema_literal,\n") - push!(parts, " execute = executeTool,\n") - if validate_code !== nothing && !isempty(validate_code) - push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") - else - push!(parts, " validateRequiredArgs = nothing,\n") - end - if prepare_code !== nothing && !isempty(prepare_code) - push!(parts, " prepareArguments = prepareArguments,\n") - else - push!(parts, " prepareArguments = nothing,\n") - end - push!(parts, " parallelToolExecute = $parallel\n") - push!(parts, " )\n") - push!(parts, "end\n") - - tool_code = join(parts) - - # Write the file — tool is loaded on next agent restart via loadTools() - write(filepath, tool_code) - - onPartialResult(Dict("status" => "Done")) - - return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], - Dict{Any,Any}( - "file" => filepath, - "name" => tool_name, - "label" => tool_label, - "description" => tool_description, - ), - nothing, false - ) -end - """ Indent a multi-line code string by the specified number of spaces. """ @@ -270,7 +125,145 @@ function getTool()::agentTool ), "required" => ["name", "label", "description", "inputSchema", "executeCode"] ), - execute = executeTool, + execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin + tool_name = get(args, "name", "")::String + tool_label = get(args, "label", tool_name)::String + tool_description = get(args, "description", "")::String + tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} + execute_code = get(args, "executeCode", "")::String + validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} + prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} + parallel = get(args, "parallel", false)::Bool + + # Validate tool name + name_err = validateToolName(tool_name) + if name_err !== nothing + return agentToolResult( + [textContent(name_err)], + Dict{Any,Any}(), nothing, false + ) + end + + # Validate required fields + if isempty(tool_name) + return agentToolResult( + [textContent("Missing required field: 'name'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(tool_description) + return agentToolResult( + [textContent("Missing required field: 'description'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(execute_code) + return agentToolResult( + [textContent("Missing required field: 'executeCode'")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Generating tool: $tool_name")) + + # Build the tool file path + script_dir = dirname(@__FILE__) + tools_dir = dirname(script_dir) + filepath = joinpath(tools_dir, "$(tool_name).jl") + + # Check for naming conflicts + if isfile(filepath) + return agentToolResult( + [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) + + # Convert schema Dict to a Julia Dict literal string + schema_literal = dict_to_julia_literal(tool_schema) + + # Build optional validation function + validate_section = if validate_code !== nothing && !isempty(validate_code) + indented = indent_code(validate_code, 4) + "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" + else + "" + end + + # Build optional prepare function + prepare_section = if prepare_code !== nothing && !isempty(prepare_code) + indented = indent_code(prepare_code, 4) + "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" + else + "" + end + + # Indent user's execute code for embedding inside execute function body + indented_exec = indent_code(execute_code, 4) + + # Escape description for Julia string literal + escaped_desc = replace(tool_description, "\\" => "\\\\") + escaped_desc = replace(escaped_desc, "\"" => "\\\"") + + # Build the complete tool file content + parts = String[] + push!(parts, "# Auto-generated tool: $tool_name\n") + push!(parts, "# Generated by writeTool at $(now())\n\n") + if !isempty(validate_section) + push!(parts, validate_section) + push!(parts, "\n") + end + if !isempty(prepare_section) + push!(parts, prepare_section) + push!(parts, "\n") + end + push!(parts, "\n") + push!(parts, "# Execute function\n") + push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") + push!(parts, "$indented_exec\n") + push!(parts, "end\n\n") + push!(parts, "# Tool definition\n") + push!(parts, "function getTool()::agentTool\n") + push!(parts, " return agentTool(\n") + push!(parts, " name = \"$(tool_name)\",\n") + push!(parts, " label = \"$(tool_label)\",\n") + push!(parts, " description = \"$(escaped_desc)\",\n") + push!(parts, " inputSchema = $schema_literal,\n") + push!(parts, " execute = executeTool,\n") + if validate_code !== nothing && !isempty(validate_code) + push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") + else + push!(parts, " validateRequiredArgs = nothing,\n") + end + if prepare_code !== nothing && !isempty(prepare_code) + push!(parts, " prepareArguments = prepareArguments,\n") + else + push!(parts, " prepareArguments = nothing,\n") + end + push!(parts, " parallelToolExecute = $parallel\n") + push!(parts, " )\n") + push!(parts, "end\n") + + tool_code = join(parts) + + # Write the file — tool is loaded on next agent restart via loadTools() + write(filepath, tool_code) + + onPartialResult(Dict("status" => "Done")) + + return agentToolResult( + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + Dict{Any,Any}( + "file" => filepath, + "name" => tool_name, + "label" => tool_label, + "description" => tool_description, + ), + nothing, false + ) + end, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index d228efe..a6a974e 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -3,9 +3,8 @@ using YiemAgent using YiemAgent.toolRegistry using YiemAgent.type -# ------------------------------------------------------------------ # -# loadTools() unit tests # -# ------------------------------------------------------------------ # +# Path to the real tools directory +TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @testset "loadTools" begin @@ -16,138 +15,99 @@ using YiemAgent.type # ------------------------------------------------------------------ # # 2. loadTools throws if a .jl file does not define getTool() # - # Must run before any other loadTools call (getTool persists in # - # module scope after include()). # + # Must run BEFORE any other loadTools call (getTool binding # + # persists in module scope after include()). # # ------------------------------------------------------------------ # bad_dir = mktempdir() write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") @test_throws ArgumentError loadTools(bad_dir) # ------------------------------------------------------------------ # - # 3. loadTools loads tool files that define getTool() # + # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - tmpdir = mktempdir() - - # Create a valid tool file (must use bare type names — include() places file in toolRegistry scope) - valid_tool_echo = """ -function getTool()::agentTool - return agentTool( - name = "testEcho", - label = "Echo Test", - description = "Echoes the input argument", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict("message" => Dict("type" => "string")), - "required" => Any["message"] - ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - return agentToolResult( - [textContent("echo: " * string(args["message"]))], - Dict{Any,Any}(), nothing, false - ) - end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - write(joinpath(tmpdir, "getEcho.jl"), valid_tool_echo) - - loaded = loadTools(tmpdir) + loaded = loadTools(TOOLS_DIR) @test !isempty(loaded) - @test length(loaded) >= 1 + @test length(loaded) == 3 names = [t.name for t in loaded] - @test "testEcho" in names - - # Check agentTool fields - echo_tool = filter(t -> t.name == "testEcho", loaded) - @test !isempty(echo_tool) - @test echo_tool[1].label == "Echo Test" - @test echo_tool[1].description == "Echoes the input argument" - @test echo_tool[1].parallelToolExecute == false - @test echo_tool[1].execute !== nothing + @test "getTime" in names + @test "getWeather" in names + @test "writeTool" in names # ------------------------------------------------------------------ # - # 4. loadTools returns tools sorted alphabetically # + # 4. loadTools returns tools sorted alphabetically by filename # + # (getTime.jl < getWeather.jl < writeTool.jl) # + # because 'T' < 'W' in ASCII # # ------------------------------------------------------------------ # - sorted_dir = mktempdir() - - tool_a = """ -function getTool()::agentTool - return agentTool( - name = "alphaTool", - label = "Alpha Tool", - description = "First tool", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("alpha")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - - tool_m = """ -function getTool()::agentTool - return agentTool( - name = "midTool", - label = "Mid Tool", - description = "Middle tool", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("mid")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - - tool_z = """ -function getTool()::agentTool - return agentTool( - name = "zuluTool", - label = "Zulu Tool", - description = "Last tool", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("zulu")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - - write(joinpath(sorted_dir, "zTool.jl"), tool_z) - write(joinpath(sorted_dir, "aTool.jl"), tool_a) - write(joinpath(sorted_dir, "mTool.jl"), tool_m) - - loaded_sorted = loadTools(sorted_dir) - # loadTools returns only tools loaded from the directory, in file-sorted order - @test length(loaded_sorted) == 3 - @test loaded_sorted[1].name == "alphaTool" - @test loaded_sorted[2].name == "midTool" - @test loaded_sorted[3].name == "zuluTool" + @test loaded[1].name == "getTime" + @test loaded[2].name == "getWeather" + @test loaded[3].name == "writeTool" # ------------------------------------------------------------------ # - # 5. getTools returns a deep copy (mutations don't affect registry) # + # 5. Verify loaded tool fields are correct # + # ------------------------------------------------------------------ # + # getTime + time_tool = loaded[1] + @test time_tool.name == "getTime" + @test time_tool.label == "Time Lookup" + @test time_tool.validateRequiredArgs !== nothing + @test time_tool.parallelToolExecute == false + @test time_tool.inputSchema["required"] == Any[] + + # getWeather + weather = loaded[2] + @test weather.name == "getWeather" + @test weather.label == "Weather Lookup" + @test weather.execute !== nothing + @test weather.parallelToolExecute == false + @test weather.inputSchema["required"] == ["city"] + + # writeTool + wt = loaded[3] + @test wt.name == "writeTool" + @test wt.label == "Create Tool" + @test wt.execute !== nothing + @test "name" in wt.inputSchema["required"] + @test "executeCode" in wt.inputSchema["required"] + + # ------------------------------------------------------------------ # + # 6. Tool execution returns valid results # + # ------------------------------------------------------------------ # + sig = nothing + op = x -> x # no-op partial result callback + + # execute getTime + result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op) + @test result_t isa agentToolResult + @test result_t.content[1] isa textContent + @test occursin("Tokyo", result_t.content[1].text) + + # execute getTime with timezone + result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op) + @test result_tz isa agentToolResult + @test occursin("America/New_York", result_tz.content[1].text) + + # execute getWeather + result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op) + @test result_w isa agentToolResult + @test result_w.content[1] isa textContent + @test occursin("Bangkok", result_w.content[1].text) + + # execute getWeather with units + result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) + @test occursin("72°F", result_w2.content[1].text) + + # ------------------------------------------------------------------ # + # 7. getTools / registerTool / clearTools # # ------------------------------------------------------------------ # registry_tools = getTools() @test !isempty(registry_tools) - orig_count = length(registry_tools) + @test any(t -> t.name == "getTime", registry_tools) + @test any(t -> t.name == "getWeather", registry_tools) - # Clear and add a new tool via registerTool clearTools() - registry_after_clear = getTools() - @test isempty(registry_after_clear) + @test isempty(getTools()) - # ------------------------------------------------------------------ # - # 6. registerTool adds to global registry # - # ------------------------------------------------------------------ # test_tool = agentTool( name = "manualTool", label = "Manual Tool", @@ -163,13 +123,10 @@ end reg = getTools() @test any(t -> t.name == "manualTool", reg) @test count(t -> t.name == "manualTool", reg) == 1 - - # parallelToolExecute flag - manual_entry = filter(t -> t.name == "manualTool", reg) - @test manual_entry[1].parallelToolExecute == true + @test reg[1].parallelToolExecute == true # ------------------------------------------------------------------ # - # 7. getTools returns deep copy # + # 8. getTools returns deep copy (mutations don't affect registry) # # ------------------------------------------------------------------ # copy1 = getTools() copy2 = getTools() -- 2.52.0 From 750eff483bd71e4a7db8d3ae9429026648396e00 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 22:08:04 +0700 Subject: [PATCH 060/112] update --- src/tools/registry.jl | 104 +++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/src/tools/registry.jl b/src/tools/registry.jl index f7aa585..4cc0a01 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -9,6 +9,9 @@ using ..type # Global registry — populated at runtime by loadTools() or registerTool() const _registry = Vector{agentTool}() +# Module references — kept alive to prevent GC of tool code that closures depend on +const _tool_modules = Vector{Module}() + # Auto-register the built-in listTools tool function __init__() registerTool(_listTool()) @@ -55,33 +58,27 @@ Scans `dir` for `.jl` files. Each file must define a function named `getTool()::agentTool`. Files are sorted alphabetically so tool registration order is deterministic. +Each `.jl` file is loaded into its own **submodule** so that all functions +defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, +and any helper functions) are namespaced and never collide with other tools. + # Tool file format -Each `.jl` file defines one function `getTool()` that returns an `agentTool`: +Each `.jl` file defines one function `getTool()` that returns an `agentTool`. +Inside the file you can freely define as many helper functions as you need — +they will all be scoped under the tool's submodule. ```julia # src/tools/getWeather.jl + +# These are namespaced — no collision with getTime.validateRequiredArgs, etc. +function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} + ... +end + function getTool()::agentTool return agentTool( name = "getWeather", - label = "Weather Lookup", - description = "Fetch current weather and forecast for a given city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City and country"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") - ), - "required" => ["city"] - ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - city = args["city"] - return agentToolResult( - [textContent("Sunny, 22C in Bangkok")], - Dict{Any,Any}(), nothing, false - ) - end, - prepareArguments = nothing, - parallelToolExecute = false + ... ) end ``` @@ -106,32 +103,57 @@ function loadTools(dir::String)::Vector{agentTool} for filename in jl_files filepath = joinpath(dir, filename) - println("[toolRegistry] Loading tool from: $filepath") - # Include the file in the current module scope so all types resolve - # (agentTool, textContent, agentToolResult, etc. are all available) - include(filepath) + # Derive a unique module name from the filename only (not full path). + # e.g. "getWeather.jl" -> "_tool_getWeather" + mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => "")) - # Validate that getTool was defined (include() places it in current module scope) - if !isdefined(@__MODULE__, :getTool) - throw(ArgumentError( - "Tool file $(filepath) does not define a `getTool()` function. " * - "Each tool file must define: function getTool()::agentTool ... end" - )) + # Build the complete module as a string and eval the parsed code. + # Julia does not allow `module ... end` inside eval(quote ...), + # and constructing the module AST by hand is fragile. + # Instead, we generate the full module source as a string, + # parse it, and eval the resulting expression. + # Also import Dates, UUIDs, DataStructures, JSON — common dependencies + # that tool files use (and that the ..type module transitively uses). + file_content = read(filepath, String) + module_code = """ + module $(mod_name) + using ..type + using Dates, UUIDs, DataStructures, JSON + $(file_content) end + """ + mod = eval(Meta.parse(module_code)) - # Call getTool() — it runs in current scope where types are visible - # Use invokelatest to handle world-age semantics after include() - tool = invokelatest(getTool) - if !(tool isa agentTool) - throw(ArgumentError( - "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" - )) + # Call getTool() via Core.eval in the submodule's scope. + # This evaluates getTool() entirely within the new module's world, + # completely avoiding world-age issues — no invokelatest needed. + # Note: all uses of `tool` must be inside the `try` block because + # Julia 1.12's SSA form doesn't track `tool` as definitely assigned + # after a `try-catch` where it's only assigned inside `try`. + try + tool = Core.eval(mod, :(getTool())) + if !(tool isa agentTool) + throw(ArgumentError( + "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" + )) + end + # Keep module reference alive — closures in the agentTool (execute, + # validateRequiredArgs, prepareArguments) may reference module-scoped + # functions. Without this, GC could collect the module. + push!(_tool_modules, mod) + push!(_registry, tool) + push!(tools, tool) + println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") + catch e + if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) + throw(ArgumentError( + "Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " * + "Each tool file must define: function getTool()::agentTool ... end" + )) + end + rethrow(e) end - - push!(_registry, tool) - push!(tools, tool) - println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") end return tools -- 2.52.0 From 92b3e4081f5e0a5c11fd647d06639f2c7ac515e3 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 22:50:02 +0700 Subject: [PATCH 061/112] update --- src/tools/getTime.jl | 33 ++++++++++++++++++++------------- src/tools/getWeather.jl | 27 +++++++++++++++++---------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 0d6a467..b58a752 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -36,6 +36,25 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end +""" +Execute the getTime tool. + +Returns mock time data for the given timezone or city. +""" +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + tz = get(args, "timezone", nothing) + city = get(args, "city", "") + if tz !== nothing + result = "Current time in $(tz): $(now())" + else + result = "Current time in $(city): $(now())" + end + return agentToolResult( + [textContent(result)], + Dict{Any,Any}(), nothing, false + ) +end + """ Define and return the getTime agentTool. """ @@ -52,19 +71,7 @@ function getTool()::agentTool ), "required" => [] ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - if tz !== nothing - result = "Current time in $(tz): $(now())" - else - result = "Current time in $(city): $(now())" - end - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), nothing, false - ) - end, + execute = executeTool, prepareArguments = nothing, validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 61a17f5..e98d6a9 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,3 +1,19 @@ +""" +Execute the getWeather tool. + +Returns mock weather data for the given city and temperature units. +""" +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + city = get(args, "city", "") + units = get(args, "units", "celsius") + temp = units == "fahrenheit" ? "72" : "22" + unit_symbol = units == "celsius" ? "°C" : "°F" + return agentToolResult( + [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], + Dict{Any,Any}(), nothing, false + ) +end + """ Define and return the getWeather agentTool. """ @@ -14,16 +30,7 @@ function getTool()::agentTool ), "required" => ["city"] ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - city = get(args, "city", "") - units = get(args, "units", "celsius") - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), nothing, false - ) - end, + execute = executeTool, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false -- 2.52.0 From 189bc2efcfe98165d8abed1c22093bae536539a2 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 09:43:35 +0700 Subject: [PATCH 062/112] update --- src/agentCore.jl | 12 ++++-------- src/tools/registry.jl | 8 ++++---- src/type.jl | 30 +++++++++++++++--------------- test/loadToolTest.jl | 16 ++++++++-------- 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 852d145..b854d18 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -516,7 +516,7 @@ function prepareToolCall( signal::Union{Nothing, abortSignal}, )::Union{preparedToolCall,immediateOutcome} - tool = find(t -> t.name == toolCall.name, context.tools) + tool = get(context.tools, toolCall.name, nothing) if tool === nothing return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end @@ -996,13 +996,9 @@ function executeToolCalls( hasSequential = false for tc in toolCalls - for t in context.tools - if t.name == tc.name && !t.parallelToolExecute - hasSequential = true - break - end - end - if hasSequential + t = get(context.tools, tc.name, nothing) + if t !== nothing && !t.parallelToolExecute + hasSequential = true break end end diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 4cc0a01..3954e8b 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -3,7 +3,7 @@ module toolRegistry export loadTools, registerTool, getTools, clearTools using Dates -using JSON +using JSON, DataStructures using ..type # Global registry — populated at runtime by loadTools() or registerTool() @@ -92,12 +92,12 @@ end # Errors - Throws `ArgumentError` if a tool file does not define a `getTool` function """ -function loadTools(dir::String)::Vector{agentTool} +function loadTools(dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end - tools = agentTool[] + tools = OrderedDict{String, agentTool}() jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) sort!(jl_files) @@ -143,7 +143,7 @@ function loadTools(dir::String)::Vector{agentTool} # functions. Without this, GC could collect the module. push!(_tool_modules, mod) push!(_registry, tool) - push!(tools, tool) + tools[tool.name] = tool println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") catch e if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) diff --git a/src/type.jl b/src/type.jl index 7078168..ee373cf 100644 --- a/src/type.jl +++ b/src/type.jl @@ -291,7 +291,7 @@ Snapshot of the agent's conversation context. # Arguments - `systemPrompt::String`: System prompt for the agent - `messages::Vector{agentMessage}`: Conversation messages -- `tools::Union{Vector{agentTool}, Nothing}`: Available tools +- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup # Returns - A new `agentContext` instance @@ -299,7 +299,7 @@ Snapshot of the agent's conversation context. struct agentContext # Snapshot of the agent's conversation context systemPrompt::String # System prompt for the agent messages::Vector{agentMessage} # Conversation messages - tools::Union{Vector{agentTool}, Nothing} # Available tools + tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name end @@ -308,9 +308,9 @@ end # ------------------------------------------------------------------------------------------------ # mutable struct agentState # Mutable runtime state of an agent - systemPrompt::String # System prompt text + systemPrompt::String # System prompt for the agent model::llmModel # LLM model to use - tools::Vector{agentTool} # Available tools + tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt messages::Vector{agentMessage} @@ -329,7 +329,7 @@ new state from external references. # Arguments - `systemPrompt::String`: System prompt text - `model::llmModel`: LLM model to use (defaults to an unknown model) -- `tools::Vector{agentTool}`: Available tools (deep copied) +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (deep copied) - `messages::Vector{agentMessage}`: Conversation messages (deep copied) # Returns @@ -338,13 +338,13 @@ new state from external references. # Examples ```julia julia> state = agentState(systemPrompt="You are a helpful assistant") -agentState("You are a helpful assistant", ..., agentTool[], agentMessage[], String[], nothing) -``` +agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing) """ function agentState( systemPrompt::String="", - model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), - tools::Vector{agentTool}=agentTool[], + model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], + modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), messages::Vector{agentMessage}=agentMessage[], ) agentState( @@ -395,13 +395,13 @@ end Configuration for the agent tool execution loop. # Arguments -- `tools::Vector{agentTool}`: Available tools +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `toolExecution::String`: Execution mode — "sequential" or "parallel" """ struct agentLoopConfig - tools::Vector{agentTool} + tools::OrderedDict{String, agentTool} beforeToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing} toolExecution::String @@ -580,7 +580,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. # Keyword Arguments - `systemPrompt::String`: System prompt for the agent - `model`: LLM model to use -- `tools::Vector{agentTool}`: Available tools (default: empty) +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) - `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) - `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) - `llmCall::Function`: Function to invoke the LLM (required) @@ -599,14 +599,14 @@ on `inputChannel` and `followUpChannel` channels concurrently. # Examples ```julia -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model) +julia> tools = loadTools("src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=...) yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) -``` """ function yiemAgent( ; systemPrompt::String="You are helpful assistant.", model=nothing, - tools::Vector{agentTool}=agentTool[], + tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), messages::Vector{agentMessage}=agentMessage[], prepareContext::Union{Function, Nothing}=nothing, formatMsgForLLM::Function=defaultformatMsgForLLM, diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index a6a974e..ad6613a 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -29,7 +29,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test !isempty(loaded) @test length(loaded) == 3 - names = [t.name for t in loaded] + names = [k for k in keys(loaded)] @test "getTime" in names @test "getWeather" in names @test "writeTool" in names @@ -39,15 +39,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # (getTime.jl < getWeather.jl < writeTool.jl) # # because 'T' < 'W' in ASCII # # ------------------------------------------------------------------ # - @test loaded[1].name == "getTime" - @test loaded[2].name == "getWeather" - @test loaded[3].name == "writeTool" + @test collect(keys(loaded))[1] == "getTime" + @test collect(keys(loaded))[2] == "getWeather" + @test collect(keys(loaded))[3] == "writeTool" # ------------------------------------------------------------------ # # 5. Verify loaded tool fields are correct # # ------------------------------------------------------------------ # # getTime - time_tool = loaded[1] + time_tool = loaded["getTime"] @test time_tool.name == "getTime" @test time_tool.label == "Time Lookup" @test time_tool.validateRequiredArgs !== nothing @@ -55,7 +55,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test time_tool.inputSchema["required"] == Any[] # getWeather - weather = loaded[2] + weather = loaded["getWeather"] @test weather.name == "getWeather" @test weather.label == "Weather Lookup" @test weather.execute !== nothing @@ -63,7 +63,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test weather.inputSchema["required"] == ["city"] # writeTool - wt = loaded[3] + wt = loaded["writeTool"] @test wt.name == "writeTool" @test wt.label == "Create Tool" @test wt.execute !== nothing @@ -123,7 +123,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") reg = getTools() @test any(t -> t.name == "manualTool", reg) @test count(t -> t.name == "manualTool", reg) == 1 - @test reg[1].parallelToolExecute == true + @test reg[1].parallelToolExecute == true # ------------------------------------------------------------------ # # 8. getTools returns deep copy (mutations don't affect registry) # -- 2.52.0 From 3891099eaa61b452aa3176c49e3bf4f36a5e3b93 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 10:39:30 +0700 Subject: [PATCH 063/112] update readme --- src/tools/README.md | 1677 ++++++++++++++++++++++++++++++++----------- 1 file changed, 1272 insertions(+), 405 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index 9e0c73c..bfe4569 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -1,211 +1,378 @@ -# Tools +# Tools — Complete End-to-End Lifecycle -Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does). +This document describes the complete tool lifecycle in the YiemAgent framework, from definition through execution, for framework authors and maintainers who need a thorough understanding of the architecture. -## Tool Anatomy +--- -Each tool has 3 main parts: +## Table of Contents -### 1. Schema (`inputSchema`) +1. [Overview](#1-overview) +2. [Tool Definition — The `agentTool` Struct](#2-tool-definition--the-agenttool-struct) +3. [Tool Registration — The Global Registry](#3-tool-registration--the-global-registry) +4. [The Agent Loop — High-Level Flow](#4-the-agent-loop--high-level-flow) +5. [Message Processing Pipeline](#5-message-processing-pipeline) +6. [Tool Call Extraction from LLM Response](#6-tool-call-extraction-from-llm-response) +7. [The Per-Call Pipeline — Prepare, Execute, Finalize](#7-the-per-call-pipeline--prepare-execute-finalize) +8. [Execution Modes — Sequential vs Parallel](#8-execution-modes--sequential-vs-parallel) +9. [Tool Call Batches & Termination Logic](#9-tool-call-batches--termination-logic) +10. [Tool Result Message Creation](#10-tool-result-message-creation) +11. [Error Handling & Recovery Pattern](#11-error-handling--recovery-pattern) +12. [Event System — Tool Lifecycle Events](#12-event-system--tool-lifecycle-events) +13. [Agent Lifecycle Hooks](#13-agent-lifecycle-hooks) +14. [Self-Modifying Tools](#14-self-modifying-tools) +15. [Complete End-to-End Example](#15-complete-end-to-end-example) +16. [Tool File Contract](#16-tool-file-contract) -JSON Schema (MCP format) describing the tool's arguments. The `"required"` array lists mandatory fields: +--- -```julia -inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City name"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") - ), - "required" => ["city"] -) +## 1. Overview + +The tool system follows a **three-phase pipeline** per tool call: + +``` +PREPARE → EXECUTE → FINALIZE ``` -### 2. Execution Function (`execute`) +Each phase has a single responsibility and produces an intermediate result: -A function with the signature: +| Phase | Function | Input | Output | Purpose | +|-------|----------|-------|--------|---------| +| Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | +| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `emit` | `executedOutcome` | Call `tool.execute()`, stream partial results | +| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | + +The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. + +--- + +## 2. Tool Definition — The `agentTool` Struct + +**Source:** `type.jl:261-281` + +Every tool is an `agentTool` struct with the following fields: ```julia -execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult -``` - -- **`toolCallId`** — unique ID for this invocation (from the LLM's tool call) -- **`args`** — validated arguments provided by the LLM -- **`signal`** — abort signal for cancellable operations -- **`onPartialResult`** — callback for streaming progress updates -- **Returns** — `agentToolResult` with content, details, usage, and termination flag - -```julia -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - # Optional: stream progress updates - onPartialResult(Dict("status" => "Fetching data...")) - - # Do work - result = "Weather in $(args["city"]): Sunny, 22°C" - - # Return result - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), # details - nothing, # usage - false # terminate (true to stop agent loop) - ) +struct agentTool + name::String # Unique identifier (e.g. "getWeather") + label::String # Human-readable name (e.g. "Weather Lookup") + description::String # What the tool does (shown to the LLM for selection) + inputSchema::Any # JSON Schema (MCP format) describing parameters + execute::Function # Core execution: (toolCallId, args, signal, onPartialResult) -> agentToolResult + prepareArguments::Union{Function, Nothing} # Optional: (args) -> modified_args (before validation) + validateRequiredArgs::Union{Function, Nothing} # Optional: (args) -> Union{Nothing, String} error + parallelToolExecute::Bool # Override: run in parallel with other tools end ``` -### 3. Tool Definition (`getTool()`) +### Field Details -Returns an `agentTool` struct: +| Field | Required | Signature | Purpose | +|-------|----------|-----------|---------| +| `name` | Yes | `String` | Unique key for tool lookup in `context.tools[name]` | +| `label` | Yes | `String` | Human-readable name for display | +| `description` | Yes | `String` | Shown to LLM for tool selection decisions | +| `inputSchema` | Yes | `Dict{String,Any}` | JSON Schema (MCP format) with `type`, `properties`, `required` | +| `execute` | Yes | `Function` | The actual tool logic (see signature below) | +| `prepareArguments` | No | `Function` | Transforms args before validation; `(args::Dict) -> Dict` | +| `validateRequiredArgs` | No | `Function` | Custom validation; `(args::Dict) -> Union{Nothing, String}` | +| `parallelToolExecute` | No | `Bool` | Default `false`. When `true`, allows parallel execution | -| Field | Type | Description | -|---|---|---| -| `name` | `String` | Unique identifier (e.g. `"getWeather"`) | -| `label` | `String` | Human-readable name (e.g. `"Weather Lookup"`) | -| `description` | `String` | What the tool does (shown to the LLM) | -| `inputSchema` | `Any` | JSON Schema (MCP format) | -| `execute` | `Function` | The execution function | -| `prepareArguments` | `Union{Function,Nothing}` | Optional argument transform before validation | -| `validateRequiredArgs` | `Union{Function,Nothing}` | Optional custom validation | -| `parallelToolExecute` | `Bool` | Run this tool in parallel with others | - -## Argument Validation - -Validation happens **before** tool execution, in the `prepareToolCall` phase. Invalid calls return an error immediately without invoking `execute`, `beforeToolCall`, or logging `toolExecutionStart`. - -### Default: JSON Schema Required Fields - -Set `validateRequiredArgs = nothing` to use the default validator, which checks that all fields in `inputSchema["required"]` are present: +### Execute Function Signature ```julia -# src/tools/getWeather.jl — uses default validation -function getTool()::agentTool - return agentTool( - name = "getWeather", - # ... - validateRequiredArgs = nothing, # uses default - ) +execute(toolCallId::String, + args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult +``` + +| Parameter | Description | +|-----------|-------------| +| `toolCallId` | Unique ID from the LLM's tool call (e.g., `"call_abc123"`) | +| `args` | Validated arguments from the LLM, already passed through `prepareArguments` and `validateRequiredArgs` | +| `signal` | Optional `abortSignal` for cancellable operations. Check `signal.aborted` to abort early. | +| `onPartialResult` | Callback for streaming progress: `onPartialResult(partial_data)` emits `toolExecUpdateEvent` | + +### Returns — `agentToolResult` + +**Source:** `type.jl:429-434` + +```julia +struct agentToolResult + content::Vector{messageContent} # Output content (textContent, etc.) + details::Dict{Any,Any} # Tool-specific metadata (e.g., counts, IDs) + usage::Union{llmUsage, Nothing} # Token usage tracking (optional) + terminate::Bool # If true, signals the tool requested loop termination end ``` -### Custom Validation Hook +The `terminate` flag is checked at the batch level. See [Section 9](#9-tool-call-batches--termination-logic) for details. -Override `validateRequiredArgs` when you need: -- **Cross-field constraints** (e.g. "at least one of X or Y") -- **Format validation** (e.g. regex patterns, date parsing) -- **Domain rules** (e.g. value ranges, business logic) +--- -The hook signature takes only `args`: +## 3. Tool Registration — The Global Registry + +**Source:** `tools/registry.jl` + +### How `loadTools()` Works ```julia -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - if !haskey(args, "timezone") && isempty(city) - return "Missing required argument: provide at least one of 'timezone' or 'city'" - end - - if tz !== nothing - tz_str = string(tz) - if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) - return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York'" +function loadTools(dir::String)::OrderedDict{String, agentTool} +``` + +**Source:** `tools/registry.jl:95-160` + +1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) +2. **Sorts** filenames alphabetically for deterministic registration order +3. **Wraps** each file in a dynamically created submodule: + ```julia + # For "getWeather.jl" → module _tool_getWeather + module _tool_getWeather + using ..type + using Dates, UUIDs, DataStructures, JSON + # (file contents here) + end + ``` +4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues +5. **Validates** the return value is an `agentTool` instance +6. **Stores** the module reference in `_tool_modules` to prevent GC of closures +7. **Registers** the tool in `_registry` and returns an `OrderedDict{String, agentTool}` + +### Why Submodules? + +Each tool file is loaded into its own **namespaced submodule**. This means: +- `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` +- No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` +- The module reference is kept alive in `_tool_modules` so closures (in `execute`, `validateRequiredArgs`, `prepareArguments`) don't get garbage collected + +### Auto-Registration + +The `_listTool()` is auto-registered in `__init__()` (line 16-18), so `listTools` is always available without explicit loading. + +### Registration API + +```julia +# Auto-load from directory +tools = loadTools("src/tools") # OrderedDict{String, agentTool} + +# Manual registration +registerTool(my_tool) # Adds to global _registry + +# Query +all_tools = getTools() # Vector{agentTool} (deep copy) + +# Clear +clearTools() # Empties _registry +``` + +--- + +## 4. The Agent Loop — High-Level Flow + +**Source:** `agentCore.jl:35-145` + +The `_agent_loop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. + +### Channel Architecture + +``` +yiemAgent struct contains: + - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() + - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() + - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() +``` + +### Loop States + +The loop tracks 6 states (documented at `agentCore.jl:39-75`): + +| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | +|-------|-----------------|-------------|----------------|-------------------|----------| +| 1 | `nothing` | `false` | empty | empty | Idle, waiting | +| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_process_message` | +| 3 | running | `true` | empty | empty | Processing, no new input | +| 4 | running | `true` | has msg | empty | New message while busy → queued | +| 5 | running | `true` | empty | has msg | Follow-up while busy → queued | +| 6 | done | `false` | empty | empty | Task completed → send result, reset | + +### Loop Logic (simplified) + +```julia +function _agent_loop(agent::yiemAgent) + while true + # 1. Wait for message from inputChannel (blocking poll) + msg = fetch!(agent.inputChannel) # agentCore.jl:84 + + # 2. Handle shutdown signal + if msg === :shutdown + drain both channels, break loop + end + + # 3. If agent is idle, spawn _process_message + if agent._state.activeRun == false + processingTask = Threads.@spawn _process_message(agent) + agent._state.activeRun = true + end + + # 4. While processing: check for followUp messages + if istaskdone(processingTask) == false && isready(agent.followUpChannel) + drain followUpChannel → push to inputChannel + continue # wait for current processing to finish + end + + # 5. When processing completes + if istaskdone(processingTask) == true + result = fetch(processingTask) + put!(agent.outputChannel, result) + agent._state.activeRun = false + processingTask = nothing end end - - return nothing end ``` -Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. +**Key design:** The loop always checks `inputChannel` before `followUpChannel`. Follow-up messages are merged into `inputChannel` only when the current processing task is active, ensuring they are processed after the primary message completes but before new input arrives. -## Tool Discovery and Lifecycle +--- -The agent iterates through tools via a **discover → execute → loop** cycle. Here is the complete flow from the framework author's perspective: +## 5. Message Processing Pipeline -### The Agent Loop +**Source:** `agentCore.jl:175-311` + +`_process_message(agent)` is the core function that processes a batch of user messages through the LLM pipeline. + +### Pipeline Steps ```julia -# agentCore.jl:175 - _process_message() -while true - # 1. Drain messages from inputChannel - while isready(agent.inputChannel) - raw_msg = take!(agent.inputChannel) - user_msg = OpenAiToUserMessage(raw_msg) - push!(agent._state.messages, user_msg) +function _process_message(agent::yiemAgent)::assistantMessage + final_response = nothing + + while true # Loop until LLM returns response without tool calls + # ── Step 1: Drain inputChannel ────────────────────────────── + while isready(agent.inputChannel) + raw_msg = take!(agent.inputChannel) + if raw_msg === :shutdown + put!(agent.inputChannel, :shutdown) + break + end + user_msg = OpenAiToUserMessage(raw_msg) # Convert Dict → userMessage + push!(agent._state.messages, user_msg) + end + + # ── Step 2: Prepare context ───────────────────────────────── + preparedContext = agent.prepareContext(agent._state) + # Default: deep copies systemPrompt, messages, tools from agentState → agentContext + # Override point: filter tools, inject context, modify system prompt + + # ── Step 3: Format for LLM ────────────────────────────────── + formatted_messages = agent.formatMsgForLLM(preparedContext) + # Converts agentContext → Dict("messages" => [...]) in OpenAI format + # Wraps systemPrompt as system role, converts each messageContent block + + # ── Step 4: Call LLM ──────────────────────────────────────── + response = agent.llmCall(formatted_messages) + # Returns assistantMessage with content::Vector{messageContent} + # Each content block has a type: "text", "thinking", or "tool_call" + + # ── Step 5: Extract tool calls ────────────────────────────── + has_tool_calls, tool_call_list = extract_tool_calls(response.content) + # Inspects content blocks for "tool_calls" or "tool_call" Dict entries + + # ── Step 6: Execute tool calls or return ──────────────────── + if has_tool_calls && !isempty(tool_call_list) + # Build context and config + context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) + config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...) + signal = nothing + emit = agent.agentEventSink + + # Execute tool calls (sequential or parallel) + batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + + # Save results to conversation history + for tool_result in batch.messages + push!(agent._state.messages, tool_result) + end + + # Check termination + if batch.terminate + final_response = build_final_response(batch) + break + end + # Otherwise, loop back to Step 1 (drain any new input) and call LLM again + else + # No tool calls — this is the final response + final_response = response + break + end end - # 2. Format messages for LLM - ctx = agent.prepareContext(agent._state) - formatted = agent.formatMsgForLLM(ctx) - - # 3. Call LLM - response = agent.llmCall(formatted) - - # 4. Check if LLM used tool calls - if has_tool_calls(response.content) - # 5. Execute tools, feed results back to LLM, loop - else - # 6. No tool calls — return final response - break - end + return final_response end ``` -### Step 1: Tool Discovery +### Debug Note -Tools are discovered from `agent._state.tools`, which is a `Vector{agentTool}` populated during agent creation: +There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts execution after the LLM call. This appears to be a debugging/staging marker. Remove or replace it before production use. + +--- + +## 6. Tool Call Extraction from LLM Response + +**Source:** `agentCore.jl:217-245` + +After the LLM call, the agent inspects `response.content` (a `Vector{messageContent}`) for tool call blocks. Two formats are supported: + +### Format 1: OpenAI `tool_calls` array ```julia -# Loading tools -tools = loadTools("src/tools") # returns Vector{agentTool} - -# Passing to agent -agent = yiemAgent( - systemPrompt = "...", - tools = tools, # ← tools stored in agent._state.tools - llmCall = my_llm_call, - agentEventSink = my_event_sink, +# Response content block: +Dict( + :type => "tool_calls", + :tool_calls => [ + Dict(:id => "call_1", :name => "getWeather", :arguments => Dict("city" => "Tokyo")), + Dict(:id => "call_2", :name => "getTime", :arguments => Dict("timezone" => "Asia/Tokyo")), + ] ) ``` -When the LLM response contains tool calls, the agent builds an `agentContext` with those tools: +### Format 2: Single `tool_call` block ```julia -context = agentContext( - agent._state.systemPrompt, - agent._state.messages, - agent._state.tools, # ← tools available for discovery +Dict( + :type => "tool_call", + :id => "call_1", + :name => "getWeather", + :arguments => Dict("city" => "Tokyo") ) ``` -### Step 2: Extract Tool Calls from LLM Response - -The agent inspects the `response.content` blocks for `tool_calls`: +### Extraction Logic ```julia -# agentCore.jl:217-245 tool_call_list = agentToolCall[] for content_block in response.content if content_block isa Dict + # OpenAI format: array of tool calls if get(content_block, :type, "") == "tool_calls" - # OpenAI format: {"type": "tool_calls", "tool_calls": [...]} for tc_data in get(content_block, :tool_calls, []) tc = agentToolCall( type = "function", - id = get(tc_data, :id, string(uuid4())), + id = get(tc_data, :id, string(uuid4())), # fallback UUID name = get(tc_data, :function, Dict())[:name], arguments = get(tc_data, :function, Dict())[:arguments], ) push!(tool_call_list, tc) end + # Alternative format: single tool call elseif get(content_block, :type, "") == "tool_call" - # Alternative format: single tool_call block + tc_data = content_block tc = agentToolCall( type = "function", - id = get(content_block, :id, string(uuid4())), - name = get(content_block, :name, ""), - arguments = get(content_block, :arguments, Dict()), + id = get(tc_data, :id, string(uuid4())), + name = get(tc_data, :name, ""), + arguments = get(tc_data, :arguments, Dict{String,Any}()), ) push!(tool_call_list, tc) end @@ -213,313 +380,1013 @@ for content_block in response.content end ``` -### Step 3: Execute Each Tool Call - -For each tool call, the agent runs through the **prepare → execute → finalize** pipeline: +The `agentToolCall` struct is defined at `type.jl:362-367`: ```julia -# agentCore.jl:247-302 -if has_tool_calls && length(tool_call_list) > 0 - context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) - config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, execution_mode) - signal = nothing - emit = agent.agentEventSink - - # Execute all tool calls (sequential or parallel) - batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) - - # Save results to conversation history - for tool_result in batch.messages - push!(agent._state.messages, tool_result) - end - - # If any tool requested termination, break the loop - if batch.terminate - final_response = build_final_response(batch) - break - end - # Otherwise, loop back to step 2 (format + call LLM again) +struct agentToolCall + type::String # Always "function" + id::String # Unique tool call identifier + name::String # Tool name (matches context.tools keys) + arguments::Dict{String, Any} # Parsed tool arguments end ``` -### Step 4: The Per-Call Pipeline +--- -Each tool call goes through three phases: +## 7. The Per-Call Pipeline — Prepare, Execute, Finalize -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PREPARE → prepareToolCall() │ -│ │ -│ 1. Find tool by name in context.tools │ -│ 2. Transform args via tool.prepareArguments (if defined) │ -│ 3. Validate via tool.validateRequiredArgs (or default) │ -│ 4. Run beforeToolCall hook (if defined) │ -│ └── on any failure → return immediateOutcome (skip execution) │ -│ └── success → return preparedToolCall │ -├─────────────────────────────────────────────────────────────────┤ -│ EXECUTE → executePreparedToolCall() │ -│ │ -│ 1. emit toolExecutionStart event │ -│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│ -│ 3. wait for all pending update events │ -│ └── on error → return executedOutcome(isError=true) │ -│ └── success → return executedOutcome(isError=false) │ -├─────────────────────────────────────────────────────────────────┤ -│ FINALIZE → finalizeExecutedToolCall() │ -│ │ -│ 1. Run afterToolCall hook (if defined) │ -│ - can mutate content, details, usage, terminate, isError │ -│ 2. emit toolExecutionEnd event │ -│ 3. createToolResultMessage → adds to conversation history │ -│ └── return finalizedOutcome │ -└─────────────────────────────────────────────────────────────────┘ -``` +This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases. -### Step 5: Feed Results Back to LLM +### 7.1 Phase 1: Prepare — `prepareToolCall()` -Tool results are added to `agent._state.messages` as `toolResultMessage` objects. On the next loop iteration, `formatMsgForLLM()` converts them to OpenAI format and the LLM receives the results: - -``` -Conversation history after tool execution: - [system] "You are a helpful assistant." - [user] "What's the weather in Tokyo?" - [assistant] (tool_calls: getWeather(city="Tokyo")) - [tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" -``` - -The LLM then decides: call another tool, or return a final text answer. - -## Execution Modes - -### Sequential - -Tools execute one at a time in order. Required when: -- Tools have implicit dependencies -- Tools share state (e.g. writing to the same file) -- Tools have `parallelToolExecute = false` - -Set globally via `agentLoopConfig.toolExecution = "sequential"`, or per-tool via `parallelToolExecute = false`. - -### Parallel - -Tools execute concurrently when all are independent. Reduces wall-clock time. Set `parallelToolExecute = true` on individual tools, or set `agentLoopConfig.toolExecution = "parallel"`. - -## Streaming Partial Results - -For long-running tools (API calls, file uploads, training), use `onPartialResult` to stream progress: +**Source:** `agentCore.jl:511-547` ```julia -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - onPartialResult(Dict("status" => "Step 1: Fetching data...")) - sleep(1) - - onPartialResult(Dict("status" => "Step 2: Processing...")) - sleep(1) - - return agentToolResult( - [textContent("Done!")], - Dict{Any,Any}(), nothing, false +function prepareToolCall( + context::agentContext, + assistantMsg::assistantMessage, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::Union{Nothing, abortSignal}, +)::Union{preparedToolCall, immediateOutcome} +``` + +**Steps:** + +1. **Resolve tool by name** — `get(context.tools, toolCall.name, nothing)` + - If `nothing` → `immediateOutcome(createErrorToolResult("Tool X not found"), true)` + +2. **Prepare arguments** — `prepareToolCallArguments(tool, toolCall)` + - Calls `tool.prepareArguments(toolCall.arguments)` if defined + - Returns the toolCall with transformed arguments + - If no hook or no change, returns original `toolCall` + +3. **Validate arguments** — `validateToolArguments(tool, prepared)` + - Calls `tool.validateRequiredArgs(prepared.arguments)` if defined, otherwise uses default `validateRequiredArgs(prepared.arguments, tool.inputSchema)` + - Default validator checks `inputSchema["required"]` array + - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below + +4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` + - Passes `assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context)` and `signal` + - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) + - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` + - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` + +5. **Return success** → `preparedToolCall(tool, toolCall, validatedArgs)` + +**Source:** `type.jl:681-685` — `preparedToolCall` holds the resolved tool, original call metadata, and validated arguments together. + +**Key design principle:** Preparation **never throws**. Every failure path returns an `immediateOutcome` with `isError=true`, ensuring the agent loop always has a valid result to feed back to the LLM. + +### 7.2 Phase 2: Execute — `executePreparedToolCall()` + +**Source:** `agentCore.jl:589-617` + +```julia +function executePreparedToolCall( + prep::preparedToolCall, + signal::Union{Nothing, abortSignal}, + emit::Function, +)::executedOutcome +``` + +**Steps:** + +1. **Initialize streaming state:** + ```julia + updateEvents = promise[] # vector to collect update event handles + accepting = true # guard to prevent duplicate emissions + ``` + +2. **Call `tool.execute()`:** + ```julia + result = prep.tool.execute( + prep.toolCall.id, + prep.args, + signal, + partialResult -> begin + if accepting + push!(updateEvents, + emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + prep.toolCall.arguments, partialResult))) + end + end + ) + ``` + +3. **Wait for streaming to settle:** + ```julia + accepting = false + wait.(updateEvents) # wait for all pending update event handlers + return executedOutcome(result, false) + ``` + +4. **On error:** + ```julia + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end + ``` + +**Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted. + +### 7.3 Phase 3: Finalize — `finalizeExecutedToolCall()` + +**Source:** `agentCore.jl:675-706` + +```julia +function finalizeExecutedToolCall( + context::agentContext, + assistantMsg::assistantMessage, + prep::preparedToolCall, + executed::executedOutcome, + config::agentLoopConfig, + signal::Union{Nothing, abortSignal}, +)::finalizedOutcome +``` + +**Steps:** + +1. **Extract execution result:** + ```julia + result = executed.result + isError = executed.isError + ``` + +2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: + - Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Hook can mutate the result: + ```julia + after = config.afterToolCall(afterCtx(...)) + if after !== nothing + result = merge(result, dict( + :content => get(after, :content, result.content), + :details => get(after, :details, result.details), + :usage => get(after, :usage, result.usage), + :terminate => get(after, :terminate, result.terminate) + )) + isError = get(after, :isError, isError) + end + ``` + - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic + - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` + +3. **Return:** + ```julia + return finalizedOutcome(prep.toolCall, result, isError) + ``` + +**Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. + +### 7.4 Emission — `emitToolExecutionEnd()` + +**Source:** `agentCore.jl:736-739` + +```julia +function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) +end +``` + +This is called immediately after finalization, before building the `toolResultMessage`. + +--- + +## 8. Execution Modes — Sequential vs Parallel + +**Source:** `agentCore.jl:795-936, 988-1011` + +### Dispatcher — `executeToolCalls()` + +```julia +function executeToolCalls( + context::agentContext, + assistantMsg::assistantMessage, + toolCalls::Vector{agentToolCall}, + config::agentLoopConfig, + signal::Union{Nothing, abortSignal}, + emit::Function, +)::agentToolCallBatch +``` + +**Decision logic** (`agentCore.jl:997-1010`): + +```julia +hasSequential = false +for tc in toolCalls + t = get(context.tools, tc.name, nothing) + if t !== nothing && !t.parallelToolExecute + hasSequential = true + break + end +end + +if config.toolExecution == "sequential" || hasSequential + return executeToolCallsSequential(...) +else + return executeToolCallsParallel(...) +end +``` + +**Rule:** If the global config is `"sequential"` **OR** any tool in the batch has `parallelToolExecute = false`, the entire batch runs sequentially. Sequential is the safe default. + +### Sequential Execution — `executeToolCallsSequential()` + +**Source:** `agentCore.jl:795-829` + +```julia +function executeToolCallsSequential(...)::agentToolCallBatch + finalizedCalls = finalizedOutcome[] + messages = toolResultMessage[] + + for tc in toolCalls + emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + + prep = prepareToolCall(context, assistantMsg, tc, config, signal) + + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + else + executed = executePreparedToolCall(prep, signal, emit) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + end + + emitToolExecutionEnd(finalized, emit) + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + + if signal !== nothing && signal.aborted + break # abort: skip remaining calls + end + end + + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) +end +``` + +### Parallel Execution — `executeToolCallsParallel()` + +**Source:** `agentCore.jl:888-936` + +```julia +function executeToolCallsParallel(...)::agentToolCallBatch + entries = union{finalizedOutcome, task{finalizedOutcome}}[] + + for tc in toolCalls + emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + + prep = prepareToolCall(context, assistantMsg, tc, config, signal) + + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + emitToolExecutionEnd(finalized, emit) + push!(entries, finalized) # immediate outcome — no task + else + task = task() do + executed = executePreparedToolCall(prep, signal, emit) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + emitToolExecutionEnd(finalized, emit) + return finalized + end + schedule(task) + push!(entries, task) # pending task + end + + if signal !== nothing && signal.aborted + break # abort during preparation — skip remaining + end + end + + # Collect results in original order + finalizedCalls = finalizedOutcome[] + for entry in entries + outcome = entry isa task ? fetch(entry) : entry + push!(finalizedCalls, outcome) + end + + messages = toolResultMessage[] + for f in finalizedCalls + push!(messages, createToolResultMessage(f)) + end + + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) +end +``` + +**Key design:** All tool calls are **prepared** sequentially (validation, hooks), then prepared calls are **executed** concurrently as separate tasks. Results are collected in the original call order via `fetch()`. + +**Trade-off:** Parallel execution reduces wall-clock time for independent tools but can overwhelm external resources (rate limits, connection pools, disk I/O). + +--- + +## 9. Tool Call Batches & Termination Logic + +**Source:** `type.jl:793-838` + +### `agentToolCallBatch` + +```julia +struct agentToolCallBatch + messages::Vector{toolResultMessage} # Tool result messages for this batch + terminate::Bool # Whether the batch should terminate the loop +end +``` + +### Termination Logic — `shouldTerminate()` + +**Source:** `agentCore.jl:409-411` + +```julia +function shouldTerminate(batches::Vector{finalizedOutcome})::Bool + return !isempty(batches) && all(b -> b.result.terminate, batches) +end +``` + +**Rule:** `terminate` is `true` **only when every tool in the batch** has `result.terminate == true`. This prevents a single tool that sets `terminate: true` (e.g., for metadata purposes) from accidentally stopping the agent when other tools did not intend to terminate. + +### When to Set `terminate = true` + +From the type documentation (`type.jl:803-815`): + +| Use Case | Description | +|----------|-------------| +| Task completion | A tool like `deploy` or `submit` finishes its work and signals the agent to stop | +| Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | +| Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | + +### Batch Processing in `_process_message()` + +**Source:** `agentCore.jl:266-307` + +```julia +batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + +# Save results to conversation history +for tool_result in batch.messages + push!(agent._state.messages, tool_result) +end + +if batch.terminate + # Build final response from tool results + final_content = [textContent("Tool execution completed.")] + for tool_result in batch.messages + for content_block in tool_result.content + if content_block isa textContent + append!(final_content, [content_block]) + elseif content_block isa Dict + if haskey(content_block, :text) + push!(final_content, textContent(content_block[:text])) + end + end + end + end + final_response = assistantMessage( + role = "assistant", + content = final_content, + api = response.api, + model = response.model, + usage = response.usage, + stopReason = "tool_use_terminated", + errorMessage = if any(x -> x.isError, batch.messages) + "One or more tool calls failed" + else + nothing + end, + timestamp = now(), + ) + break # exit the while loop +end +# If batch.terminate == false, loop back to call LLM again with tool results +``` + +--- + +## 10. Tool Result Message Creation + +**Source:** `agentCore.jl:373-379` + +### `createToolResultMessage()` + +```julia +function createToolResultMessage(f::finalizedOutcome)::toolResultMessage + return toolResultMessage( + "toolResult", # role + f.toolCall.id, # toolCallId + f.toolCall.name, # toolName + f.result.content, # content (Vector{messageContent}) + f.result.details, # details + f.result.usage, # usage + get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools) + f.isError, # isError + nowMillis(), # timestamp ) end ``` -UI listeners and the TUI consume these events in real time via `toolExecutionUpdate`. +### `toolResultMessage` Struct -## Loading Tools - -### Auto-load from Directory +**Source:** `type.jl:152-191` ```julia -using .toolRegistry - -tools = loadTools("src/tools") # scans for *.jl files with getTool() +struct toolResultMessage <: agentMessage + role::String # Always "tool" + toolCallId::String # ID matching the original tool call + toolName::String # Name of the executed tool + content::Vector{messageContent} # Tool output content + details::Any # Additional tool-specific details + usage::Union{llmUsage, Nothing} # Token usage if applicable + addedToolNames::Union{Vector{String}, Nothing} # Tools added during execution + isError::Bool # Whether the tool call resulted in an error + timestamp::Timestamp # When the result was recorded +end ``` -Files are loaded alphabetically for deterministic registration order. - -### Manual Registration - -```julia -tool = getTool() # from your tool module -registerTool(tool) -``` - -## Complete Lifecycle Example - -```julia -# ─── USER SENDS MESSAGE ─────────────────────────────────────────── -run_agent(agent, "What's the weather in Tokyo?") - -# ─── LOOP ITERATION 1 ───────────────────────────────────────────── -# Agent formats messages and calls LLM -formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) -response = agent.llmCall(formatted) -# LLM returns: {"content": [{"type": "tool_calls", "tool_calls": [{"name": "getWeather", "arguments": {"city": "Tokyo"}}]}]} - -# Agent extracts tool call, builds context -context = agentContext(systemPrompt, messages, agent._state.tools) -tool_call_list = [agentToolCall("call_1", "getWeather", Dict("city" => "Tokyo"))] - -# PREPARE: find tool, validate args -tool = find(t -> t.name == "getWeather", context.tools) # found! -validateRequiredArgs(Dict("city" => "Tokyo"), tool.inputSchema) # passes -beforeToolCall_hook(agentMsgCtx, nothing) # nil, skipped - -# EXECUTE: call tool.execute() -result = tool.execute("call_1", Dict("city" => "Tokyo"), nothing, onPartialResult) -# Returns: agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], Dict(), nothing, false) - -# FINALIZE: afterToolCall hook, emit events -finalized = finalizedOutcome(tc, result, false) -emit(toolExecEndEvent("call_1", "getWeather", result, false)) -msg = createToolResultMessage(finalized) # toolResultMessage for conversation history - -# Add result to conversation -push!(agent._state.messages, msg) -# Messages now: [user: "What's the weather?", assistant: {tool_calls: getWeather}, tool: "Sunny, 22°C"] - -# ─── LOOP ITERATION 2 ───────────────────────────────────────────── -# LLM called again with tool result included -formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) -response = agent.llmCall(formatted) -# LLM returns: {"content": [{"type": "text", "text": "The weather in Tokyo is sunny, 22°C."}]} - -# No tool calls detected → break loop, return final response -return assistantMessage(content=[textContent("The weather in Tokyo is sunny, 22°C.")], ...) - -# ─── USER RECEIVES RESPONSE ─────────────────────────────────────── -response = take_response(agent) -println(response.content) -# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" -``` - -## Self-Modifying Tools - -The framework includes tools that allow the agent to create new tools at runtime. - -### `writeTool` — Create New Tool Files - -`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (the actual Julia code), and `writeTool` wraps it in the required boilerplate. - -**How it works:** - -The LLM constructs `writeTool` with: -- **`executeCode`** — the actual tool logic (Julia code body, NOT wrapped in a function) -- **`name`, `label`, `description`** — tool metadata -- **`inputSchema`** — parameter schema in MCP format -- **`validateCode`, `prepareCode`** (optional) — custom validation/preparation logic - -`writeTool` produces `src/tools/.jl` by: -1. Converting the `inputSchema` Dict into a Julia `Dict{String,Any}(...)` string literal -2. Indenting `executeCode` with 4 spaces -3. Wrapping it inside a `function executeTool(...)::agentToolResult ... end` template -4. Appending the `getTool()` definition that returns an `agentTool` struct -5. Writing the combined string to disk - -**Workflow:** +### Conversation History After Tool Execution ``` -LLM decides: "Need a searchWine tool. I'll provide the logic." - -LLM calls writeTool: - name: "searchWine" - executeCode: "query = args[\"query\"]\nresult = search(query)\nreturn ..." - -writeTool wraps it → src/tools/searchWine.jl: - function executeTool(...)::agentToolResult - query = args["query"] ← LLM code (indented 4 spaces) - result = search(query) - return agentToolResult(...) - end - - function getTool()::agentTool - return agentTool(name="searchWine", ...) - end - -Restart → loadTools("src/tools") loads searchWine.jl +[system] "You are a helpful assistant." +[user] "What's the weather in Tokyo?" +[assistant] {tool_calls: [getWeather(city="Tokyo")]} +[tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" ``` -**Example specification:** +On the next loop iteration, `formatMsgForLLM()` converts `toolResultMessage` to OpenAI format: ```julia Dict( - "name" => "searchWine", - "label" => "Wine Search", - "description" => "Search a wine database by name, region, or variety", - "inputSchema" => Dict( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string", "description" => "Search query"), - "maxResults" => Dict("type" => "integer", "default" => 10) - ), - "required" => ["query"] - ), - "executeCode" => """ - query = args["query"] - max_results = get(args, "maxResults", 10) - # Perform search logic here - result = "Found 3 wines matching: $query" - return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) - """, - "parallel" => false + "role" => "tool", + "tool_call_id" => "call_1", + "content" => [Dict("type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C")] ) ``` -**Optional hooks:** +--- -| Field | Description | -|---|---| -| `validateCode` | Custom validation Julia code (runs before execute). Return `nothing` to pass, or an error `String` to fail. | -| `prepareCode` | Argument preparation code (runs before validation). Return modified args dict. | +## 11. Error Handling & Recovery Pattern + +The framework uses a **result-based error handling** pattern instead of exceptions for tool call failures. This ensures the LLM always receives a tool result message, giving it the information to recover. + +### Error Flow + +``` +Tool call fails at any phase + │ + ▼ +┌─────────────────────────┐ +│ Phase: Prepare │ → immediateOutcome(error_result, true) +│ Phase: Execute │ → executedOutcome(error_result, true) +│ Phase: Finalize (hook) │ → finalizedOutcome(tc, error_result, true) +└─────────────────────────┘ + │ + ▼ +emitToolExecutionEnd(finalized, emit) + │ + ▼ +createToolResultMessage(finalized) + │ + ▼ +push!(agent._state.messages, toolResultMessage) + │ + ▼ +formatMsgForLLM() → LLM receives error as tool result + │ + ▼ +LLM can: retry with corrected args, report failure, or ask user for clarification +``` + +### `createErrorToolResult()` + +**Source:** `agentCore.jl:339-341` + +```julia +function createErrorToolResult(msg::String)::agentToolResult + return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) +end +``` + +Returns an `agentToolResult` with a single `textContent` block containing the error message. This is wrapped in an `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` depending on where the error occurred, then converted to `toolResultMessage` for the LLM. + +**Key design:** Returning a result instead of throwing allows the LLM to see the error and decide whether to retry, re-issue the call with different arguments, or report failure to the user. + +--- + +## 12. Event System — Tool Lifecycle Events + +**Source:** `type.jl:480-516` + +Each tool call emits a three-event lifecycle: + +``` +toolExecStartEvent → [zero or more toolExecUpdateEvent] → toolExecEndEvent +``` + +### Event Types + +```julia +struct toolExecStartEvent + toolCallId::String # ID of the tool call + toolName::String # Name of the tool + arguments::Dict{String, Any} # Tool arguments +end + +struct toolExecUpdateEvent + toolCallId::String # ID of the tool call + toolName::String # Name of the tool + arguments::Dict{String, Any} # Tool arguments + partialResult::Any # The partial result data +end + +struct toolExecEndEvent + toolCallId::String # ID of the tool call + toolName::String # Name of the tool + result::agentToolResult # The final tool result + isError::Bool # Whether execution resulted in an error +end +``` + +### Event Emission Points + +| Event | Emitted From | When | +|-------|-------------|------| +| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | +| `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | +| `toolExecEndEvent` | `emitToolExecutionEnd()` | After `finalizeExecutedToolCall()` for each tool call | + +### Event Sink + +The `emit` function is passed through the entire call chain: + +```julia +emit = agent.agentEventSink # set during yiemAgent construction +``` + +The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by: +- **TUI (Terminal UI):** Display real-time progress, tool names, results +- **Logging systems:** Record tool execution history +- **Monitoring:** Track tool usage, execution times, error rates +- **Audit trails:** Log all tool calls with arguments and results + +--- + +## 13. Agent Lifecycle Hooks + +### Hook Types + +| Hook | Signature | Called | Purpose | +|------|-----------|--------|---------| +| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | +| `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | +| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | +| `beforeToolCall` | `(msgCtx::assistantMsgCtx, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | +| `afterToolCall` | `(afterCtx::afterCtx, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | +| `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | + +### `beforeToolCall` Hook + +**Source:** `agentCore.jl:530-541` + +```julia +if config.beforeToolCall !== nothing + before = config.beforeToolCall( + assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + ) + if signal !== nothing && signal.aborted + return immediateOutcome(createErrorToolResult("Operation aborted"), true) + end + if before !== nothing && before.block + return immediateOutcome( + createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) + end +end +``` + +**Return values:** +- `nothing` — proceed with execution +- `Dict(:block => true, :reason => "...")` — block execution, error fed back to LLM +- `Dict(:block => false)` — proceed (explicit allow) + +### `afterToolCall` Hook + +**Source:** `agentCore.jl:687-703` + +```julia +if config.afterToolCall !== nothing + try + after = config.afterToolCall( + afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + ) + if after !== nothing + result = merge(result, dict( + :content => get(after, :content, result.content), + :details => get(after, :details, result.details), + :usage => get(after, :usage, result.usage), + :terminate => get(after, :terminate, result.terminate) + )) + isError = get(after, :isError, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true + end +end +``` + +**Common use cases:** +- Mask sensitive data from result content before the LLM sees it (e.g., removing API keys from error messages) +- Normalize usage tracking data into a consistent format +- Inspect the result and decide to flip `terminate: true` based on business logic +- Wrap an error result in a friendlier message for the LLM to understand + +### `prepareContext` Hook + +**Source:** `utils.jl:111-125` + +```julia +function prepareContext(state::agentState)::agentContext + # TODO: filter tools from state.tools based on user intent + filteredTools = state.tools + + # TODO: add filtered tools to the current system prompt / modify systemPrompt + preparedSystemPrompt = state.systemPrompt + + # TODO: add system prompt, adjust/modify and inject additional context into messages + preparedMessages = deepcopy(state.messages) + + return agentContext(preparedSystemPrompt, preparedMessages, filteredTools) +end +``` + +**Override points for customization:** +- Filter tools based on user intent (e.g., only show "wine" tools when user asks about wine) +- Modify the system prompt dynamically (e.g., inject current time, user preferences) +- Inject additional context (e.g., retrieved documents, current user state) +- Prune or reorder messages before formatting for the LLM + +### `formatMsgForLLM` Hook + +**Source:** `utils.jl:158-219` + +Default implementation converts `agentContext` to OpenAI-compatible format: + +```julia +function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} + messages = Vector{Dict{String, Any}}() + + # System prompt as system message + if !isempty(ctx.systemPrompt) + push!(messages, Dict( + "role" => "system", + "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] + )) + end + + # Conversation messages + for msg in ctx.messages + if msg isa userMessage + push!(messages, _userMessageToOpenAI(msg)) + elseif msg isa assistantMessage + push!(messages, _assistantMessageToOpenAI(msg)) + elseif msg isa toolResultMessage + push!(messages, _toolResultMessageToOpenAI(msg)) + end + end + + return Dict("messages" => messages) +end +``` + +Override this to produce custom LLM message formats for different APIs/providers (e.g., Anthropic, Google, Ollama). + +--- + +## 14. Self-Modifying Tools + +The framework supports tools that modify the tool system itself at runtime. + +### `writeTool` — Create New Tool Files + +**Source:** `tools/writeTool.jl` + +`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (Julia code body), and `writeTool` wraps it in Julia boilerplate: + +1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal +2. Indents `executeCode` with 4 spaces +3. Wraps it inside `function executeTool(...)::agentToolResult ... end` +4. Appends `getTool()` returning an `agentTool` struct +5. Writes the combined string to `src/tools/.jl` ### `listTools` — Discover Available Tools -Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool` — the LLM checks existing names before picking a unique one. +**Source:** `tools/registry.jl:24-52` + +Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. + +### Self-Tooling Workflow + +``` +1. Agent detects no existing tool handles the user's request +2. Agent calls writeTool with: + - name: "searchWine" + - label: "Wine Search" + - description: "Search a wine database..." + - inputSchema: { ... } + - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." + - (optional) validateCode, prepareCode +3. writeTool generates src/tools/searchWine.jl +4. Agent restarts (or hot-reloads) → loadTools("src/tools") picks up the new file +5. Agent calls searchWine(query="cabernet") +6. Result: "Found 5 cabernet wines..." +``` + +### `writeTool` Input Schema + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `String` | Yes | Valid Julia identifier (letters, digits, underscores) | +| `label` | `String` | Yes | Human-readable tool name | +| `description` | `String` | Yes | What the tool does | +| `inputSchema` | `Dict` | Yes | JSON Schema in MCP format | +| `executeCode` | `String` | Yes | Julia code for `executeTool` body (NOT wrapped in function) | +| `validateCode` | `String` | No | Custom validation Julia code | +| `prepareCode` | `String` | No | Argument preparation code | +| `parallel` | `Bool` | No | Whether the tool can run in parallel (default: `false`) | + +--- + +## 15. Complete End-to-End Example + +### Full Lifecycle: User Message to Tool Result + +``` +USER SENDS MESSAGE + └─> run_agent(agent, "What's the weather in Tokyo?") + └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) + + +LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL + └─> _agent_loop: detects msg in inputChannel + └─> Threads.@spawn _process_message(agent) + + ── _process_message ────────────────────────────────────────────── + │ + │ Step 1: Drain inputChannel + │ raw_msg = Dict("role" => "user", "content" => [...]) + │ user_msg = OpenAiToUserMessage(raw_msg) + │ push!(agent._state.messages, user_msg) + │ + │ Step 2: prepareContext + │ ctx = agent.prepareContext(agent._state) + │ → agentContext(systemPrompt, messages, tools) + │ + │ Step 3: formatMsgForLLM + │ formatted = agent.formatMsgForLLM(ctx) + │ → Dict("messages" => [ + │ Dict("role" => "system", "content" => [...]), + │ Dict("role" => "user", "content" => [...]), + │ ]) + │ + │ Step 4: llmCall + │ response = agent.llmCall(formatted) + │ → assistantMessage(content = [ + │ Dict(:type => "tool_calls", :tool_calls => [ + │ Dict(:id => "call_1", :name => "getWeather", + │ :arguments => Dict("city" => "Tokyo")) + │ ]) + │ ]) + │ + │ Step 5: Extract tool calls + │ tool_call_list = [agentToolCall("function", "call_1", "getWeather", ...)] + │ + │ Step 6: Execute tool calls + │ context = agentContext(systemPrompt, messages, tools) + │ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential") + │ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit) + │ + │ ── executeToolCallsSequential ────────────────────────────── + │ │ + │ │ For tc = agentToolCall("call_1", "getWeather", ...): + │ │ + │ │ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"})) + │ │ + │ │ PREPARE: + │ │ tool = context.tools["getWeather"] → found! + │ │ validatedArgs = validateToolArguments(tool, tc) + │ │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes + │ │ beforeToolCall_hook(...) → nothing (skipped) + │ │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) + │ │ + │ │ EXECUTE: + │ │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult) + │ │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) + │ │ → executedOutcome(result, false) + │ │ + │ │ FINALIZE: + │ │ afterToolCall_hook(...) → nothing (skipped) + │ │ → finalizedOutcome(tc, result, false) + │ │ + │ │ emit(toolExecEndEvent("call_1", "getWeather", result, false)) + │ │ msg = createToolResultMessage(finalized) + │ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) + │ │ + │ └─> agentToolCallBatch([msg], false) + │ + │ Save results: + │ for tool_result in batch.messages + │ push!(agent._state.messages, tool_result) + │ end + │ + │ batch.terminate == false → loop back to Step 1 + │ + + +LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE + ── _process_message (second iteration) ─────────────────────────── + │ + │ Step 1: Drain inputChannel → empty + │ + │ Step 2-3: prepareContext → formatMsgForLLM + │ → messages now include: + │ [system] "You are a helpful assistant." + │ [user] "What's the weather in Tokyo?" + │ [tool] tool_call_id="call_1", content="Weather in Tokyo: Sunny, 22°C" + │ + │ Step 4: llmCall + │ → assistantMessage(content = [Dict(:type => "text", :text => "The weather in Tokyo is sunny, 22°C.")]) + │ + │ Step 5: Extract tool calls → none + │ + │ Step 6: has_tool_calls == false → break, return final_response + │ + └─> return final_response + + +AGENT LOOP: SEND RESPONSE TO USER + └─> put!(agent.outputChannel, final_response) + └─> take_response(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") +``` + +--- + +## 16. Tool File Contract + +Each `.jl` file in `src/tools/` must conform to the following contract: + +### Required Function ```julia -# Result from listTools: -# Available tools: -# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city. -# - getTime: Time Lookup — Get current local time for a timezone or city. -# - writeTool: Create Tool — Generate new tool files... -# - listTools: List Tools — List all available tools with their names and labels... +function getTool()::agentTool + # Must return an agentTool instance +end ``` -### Complete Self-Tooling Example +### Optional Functions -``` -User: "I need to search for wines. Do you have a tool for that?" +```julia +# Argument preparation (before validation) +function prepareArguments(args::Dict{String,Any})::Dict{String,Any} + # Return modified args, or args unchanged + return args +end -# ─── LOOP: Agent realizes no wine search tool exists ───────────────── +# Custom validation (before execution) +function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} + # Return nothing to pass, or error string to fail + return nothing +end -# LLM generates the tool logic and calls writeTool to write it to disk - -[Tool Call] writeTool(name="searchWine", label="Wine Search", - description="Search a wine database by name, region, or variety", - inputSchema={...}, - executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") - -# writeTool generates src/tools/searchWine.jl - -# ─── SYSTEM RESTARTS ───────────────────────────────────────────────── - -# loadTools("src/tools") loads searchWine.jl alongside all other tools - -# ─── Agent calls the new tool ───────────────────────────────────────── - -[Tool Call] searchWine(query="cabernet", maxResults=5) -# Result: "Found 5 cabernet wines..." - -# ─── Final response ────────────────────────────────────────────────── - -"The search found 5 cabernet wines: ..." +# Core execution +function executeTool(toolCallId::String, + args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult + # Return agentToolResult with content, details, usage, terminate + return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false) +end ``` -## Available Tools +### File Structure -| Tool | Description | Validation | -|---|---|---| -| `getWeather` | Fetch weather for a city | Default (JSON Schema required) | -| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | -| `writeTool` | Create a new Julia tool module at runtime | Built-in (name + schema validation) | -| `listTools` | List all available tools with descriptions | None (no arguments) | +```julia +# src/tools/myTool.jl + +# Optional: helper functions +function helper_function(...) + ... +end + +# Optional: prepareArguments +function prepareArguments(args::Dict{String,Any})::Dict{String,Any} + return args +end + +# Optional: validateRequiredArgs +function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} + return nothing +end + +# Required: executeTool +function executeTool(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult + ... +end + +# Required: getTool +function getTool()::agentTool + return agentTool( + name = "myTool", + label = "My Tool", + description = "What this tool does", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict(...), + "required" => [...] + ), + execute = executeTool, + prepareArguments = prepareArguments, + validateRequiredArgs = validateRequiredArgs, + parallelToolExecute = false + ) +end +``` + +### Module Isolation + +When `loadTools()` loads a file, it wraps it in a dynamically created submodule: + +```julia +# User writes in src/tools/myTool.jl: +function getTool()::agentTool ... end + +# loadTools() creates: +module _tool_myTool + using ..type + using Dates, UUIDs, DataStructures, JSON + # (user's code here) +end +``` + +All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive in `_tool_modules` to prevent garbage collection of closures. + +--- + +## Appendix: Type Reference + +### Message Types + +| Type | Source | Description | +|------|--------|-------------| +| `messageContent` | `type.jl:67` | Abstract base for message content | +| `textContent` | `type.jl:69` | Plain text content (`text::String`) | +| `imageContent` | `type.jl:73` | Image content (`data::String`, `mimeType::String`) | +| `agentMessage` | `type.jl:82` | Abstract base for all messages | +| `userMessage` | `type.jl:84` | User message (`role`, `content`, `timestamp`) | +| `assistantMessage` | `type.jl:111` | LLM response (`role`, `content`, `api`, `provider`, `model`, `usage`, `stopReason`, `errorMessage`, `timestamp`) | +| `toolResultMessage` | `type.jl:152` | Tool result (`role`, `toolCallId`, `toolName`, `content`, `details`, `usage`, `addedToolNames`, `isError`, `timestamp`) | + +### Tool Types + +| Type | Source | Description | +|------|--------|-------------| +| `agentTool` | `type.jl:261` | Tool definition (name, label, description, schema, execute, hooks) | +| `agentToolCall` | `type.jl:362` | Tool call from LLM (type, id, name, arguments) | +| `agentToolResult` | `type.jl:429` | Tool execution result (content, details, usage, terminate) | +| `agentToolCallBatch` | `type.jl:835` | Batch of tool results (messages, terminate) | + +### Lifecycle Outcome Types + +| Type | Source | Description | +|------|--------|-------------| +| `preparedToolCall` | `type.jl:681` | After prepare: (tool, toolCall, args) | +| `immediateOutcome` | `type.jl:719` | Failed before execution: (result, isError) | +| `executedOutcome` | `type.jl:753` | After execute, before finalize: (result, isError) | +| `finalizedOutcome` | `type.jl:787` | After all phases: (toolCall, result, isError) | + +### Context & Config Types + +| Type | Source | Description | +|------|--------|-------------| +| `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | +| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | +| `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | +| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | +| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | +| `afterCtx` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | + +### Event Types + +| Type | Source | Description | +|------|--------|-------------| +| `toolExecStartEvent` | `type.jl:480` | (toolCallId, toolName, arguments) | +| `toolExecUpdateEvent` | `type.jl:495` | (toolCallId, toolName, arguments, partialResult) | +| `toolExecEndEvent` | `type.jl:511` | (toolCallId, toolName, result, isError) | + +### Agent Types + +| Type | Source | Description | +|------|--------|-------------| +| `agent` | `type.jl:522` | Abstract base type | +| `yiemAgent` | `type.jl:527` | High-level agent wrapper (state, channels, callbacks, task) | -- 2.52.0 From 1b69f69c7d626fdba9ed2c985dc7146042c12174 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 13:33:45 +0700 Subject: [PATCH 064/112] update --- src/tools/README.md | 49 +++++++++++++++++++++++++++++++++++------- src/tools/getTime.jl | 2 ++ src/tools/registry.jl | 20 +++++++++++------ src/tools/writeTool.jl | 2 ++ test/loadToolTest.jl | 15 ++++++++----- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index bfe4569..a64b026 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -152,19 +152,24 @@ The `_listTool()` is auto-registered in `__init__()` (line 16-18), so `listTools ### Registration API ```julia -# Auto-load from directory +# Auto-load from directory (returns OrderedDict keyed by tool name) tools = loadTools("src/tools") # OrderedDict{String, agentTool} -# Manual registration -registerTool(my_tool) # Adds to global _registry +# Manual registration (adds to global _registry) +registerTool(my_tool) -# Query -all_tools = getTools() # Vector{agentTool} (deep copy) +# Query (returns OrderedDict keyed by tool name, in registration order) +all_tools = getTools() # OrderedDict{String, agentTool} — O(1) lookup + deterministic order # Clear clearTools() # Empties _registry ``` +**Why `OrderedDict` for `getTools()`?** The internal `_registry` is a `Vector{agentTool}` for ordered iteration (used by `listTools`). `getTools()` builds an `OrderedDict` from `_registry` so callers get: +- O(1) lookup by tool name +- Deterministic iteration order (registration order: `listTools` auto-registered first, then tools loaded alphabetically by filename) +- Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) + --- ## 4. The Agent Loop — High-Level Flow @@ -1272,6 +1277,8 @@ end ```julia # src/tools/myTool.jl +using Dates # ← tool declares its own dependencies (registry injects only `using ..type`) + # Optional: helper functions function helper_function(...) ... @@ -1313,18 +1320,44 @@ function getTool()::agentTool end ``` +### Dependencies + +Each tool file **declares its own dependencies** via `using` statements at the top of the file. The registry does **not** inject any standard library packages — if a tool needs `Dates`, `JSON`, `HTTP`, `CSV`, or any other package, it must include its own `using` statements. + +```julia +# src/tools/getTime.jl +using Dates + +function executeTool(...) + now() # Dates.now requires `using Dates` +end +``` + +```julia +# src/tools/myApiTool.jl +using HTTP, JSON + +function executeTool(...) + response = HTTP.get("https://api.example.com") + data = JSON.parse(String(response.body)) + ... +end +``` + ### Module Isolation -When `loadTools()` loads a file, it wraps it in a dynamically created submodule: +When `loadTools()` loads a file, it wraps it in a dynamically created submodule. The registry injects **only** `using ..type` to make core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`, etc.) available: ```julia # User writes in src/tools/myTool.jl: +using Dates, HTTP, JSON # ← tool's own dependencies + function getTool()::agentTool ... end # loadTools() creates: module _tool_myTool - using ..type - using Dates, UUIDs, DataStructures, JSON + using ..type # ← injected by registry (core types only) + using Dates, HTTP, JSON # ← from tool file # (user's code here) end ``` diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index b58a752..94b7752 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -1,3 +1,5 @@ +using Dates + """ Validate required arguments for the getTime tool. diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 3954e8b..0884853 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -113,13 +113,13 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} # and constructing the module AST by hand is fragile. # Instead, we generate the full module source as a string, # parse it, and eval the resulting expression. - # Also import Dates, UUIDs, DataStructures, JSON — common dependencies - # that tool files use (and that the ..type module transitively uses). + # Each tool file declares its own dependencies via `using` statements + # at the top of the file — the registry only injects `using ..type` + # to make core types (agentTool, textContent, etc.) available. file_content = read(filepath, String) module_code = """ module $(mod_name) using ..type - using Dates, UUIDs, DataStructures, JSON $(file_content) end """ @@ -175,13 +175,19 @@ function registerTool(tool::agentTool)::Vector{agentTool} end """ -Get all registered tools. +Get all registered tools as an `OrderedDict{String, agentTool}` keyed by tool name. + +The internal `_registry` is a `Vector` for ordered iteration (used by `listTools`). +This function builds an `OrderedDict` from `_registry` so callers get: +- O(1) lookup by name +- Deterministic iteration order (registration order: alphabetical by filename) +- Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) # Returns -- `Vector{agentTool}`: Copy of the registry +- `OrderedDict{String, agentTool}`: Copy of the registry keyed by tool name, in registration order """ -function getTools()::Vector{agentTool} - return deepcopy(_registry) +function getTools()::OrderedDict{String, agentTool} + return OrderedDict{String, agentTool}(t.name => t for t in _registry) end """ diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index 40c4d8c..73dc6da 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -1,3 +1,5 @@ +using JSON + """ Tool that writes new Julia tool module files to disk. diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index ad6613a..f0f10df 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -102,8 +102,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # registry_tools = getTools() @test !isempty(registry_tools) - @test any(t -> t.name == "getTime", registry_tools) - @test any(t -> t.name == "getWeather", registry_tools) + @test "getTime" in keys(registry_tools) + @test "getWeather" in keys(registry_tools) + # listTools is auto-registered via __init__() → first key, then tools loaded alphabetically + @test collect(keys(registry_tools))[1] == "listTools" + @test collect(keys(registry_tools))[2] == "getTime" + @test collect(keys(registry_tools))[3] == "getWeather" + @test collect(keys(registry_tools))[4] == "writeTool" clearTools() @test isempty(getTools()) @@ -121,9 +126,9 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") ) registerTool(test_tool) reg = getTools() - @test any(t -> t.name == "manualTool", reg) - @test count(t -> t.name == "manualTool", reg) == 1 - @test reg[1].parallelToolExecute == true + @test haskey(reg, "manualTool") + @test length(reg) == 1 + @test reg["manualTool"].parallelToolExecute == true # ------------------------------------------------------------------ # # 8. getTools returns deep copy (mutations don't affect registry) # -- 2.52.0 From c78f4b023d96e9a9d46ab9fe3f761905d3d82297 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 14:55:09 +0700 Subject: [PATCH 065/112] update --- src/tools/README.md | 101 ++++++++++++++++--------- src/tools/registry.jl | 163 +++++++++++++++++++++++++++-------------- src/tools/writeTool.jl | 6 +- src/type.jl | 17 +++-- test/loadToolTest.jl | 74 +++++++++++++------ 5 files changed, 242 insertions(+), 119 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index a64b026..0f4cc43 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -8,7 +8,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework, 1. [Overview](#1-overview) 2. [Tool Definition — The `agentTool` Struct](#2-tool-definition--the-agenttool-struct) -3. [Tool Registration — The Global Registry](#3-tool-registration--the-global-registry) +3. [Tool Registration — Per-Agent Tool Stores](#3-tool-registration--per-agent-tool-stores) 4. [The Agent Loop — High-Level Flow](#4-the-agent-loop--high-level-flow) 5. [Message Processing Pipeline](#5-message-processing-pipeline) 6. [Tool Call Extraction from LLM Response](#6-tool-call-extraction-from-llm-response) @@ -110,66 +110,98 @@ The `terminate` flag is checked at the batch level. See [Section 9](#9-tool-call --- -## 3. Tool Registration — The Global Registry +## 3. Tool Registration — Per-Agent Tool Stores **Source:** `tools/registry.jl` -### How `loadTools()` Works +### How `ToolStore` Works + +The registry uses **per-agent isolated storage** via the `ToolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. ```julia -function loadTools(dir::String)::OrderedDict{String, agentTool} +struct ToolStore + tools::Vector{agentTool} # ordered tool list (for listTool iteration) + modules::Vector{Module} # keeps tool submodules alive to prevent GC + name::String # identifier for debugging/logs +end ``` -**Source:** `tools/registry.jl:95-160` +### How `loadTools(store, dir)` Works + +```julia +function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} +``` + +**Source:** `tools/registry.jl:115-180` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order 3. **Wraps** each file in a dynamically created submodule: - ```julia - # For "getWeather.jl" → module _tool_getWeather - module _tool_getWeather - using ..type - using Dates, UUIDs, DataStructures, JSON - # (file contents here) - end - ``` + ```julia + # For "getWeather.jl" → module _tool_getWeather + module _tool_getWeather + using ..type + using Dates, UUIDs, DataStructures, JSON + # (file contents here) + end + ``` 4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues 5. **Validates** the return value is an `agentTool` instance -6. **Stores** the module reference in `_tool_modules` to prevent GC of closures -7. **Registers** the tool in `_registry` and returns an `OrderedDict{String, agentTool}` +6. **Stores** the module reference in `store.modules` to prevent GC of closures +7. **Registers** the tool in `store.tools` and returns an `OrderedDict{String, agentTool}` ### Why Submodules? Each tool file is loaded into its own **namespaced submodule**. This means: - `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` - No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` -- The module reference is kept alive in `_tool_modules` so closures (in `execute`, `validateRequiredArgs`, `prepareArguments`) don't get garbage collected - -### Auto-Registration - -The `_listTool()` is auto-registered in `__init__()` (line 16-18), so `listTools` is always available without explicit loading. +- The module reference is kept alive in `store.modules` so closures (in `execute`, `validateRequiredArgs`, `prepareArguments`) don't get garbage collected ### Registration API ```julia -# Auto-load from directory (returns OrderedDict keyed by tool name) -tools = loadTools("src/tools") # OrderedDict{String, agentTool} +# Create per-agent stores +store1 = ToolStore(name="agent1") +store2 = ToolStore(name="agent2") -# Manual registration (adds to global _registry) -registerTool(my_tool) +# Load tools into specific stores +tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only +tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only + +# Manual registration (per-store) +registerTool(store1, my_tool) # Query (returns OrderedDict keyed by tool name, in registration order) -all_tools = getTools() # OrderedDict{String, agentTool} — O(1) lookup + deterministic order +all_tools = getTools(store1) # OrderedDict{String, agentTool} — O(1) lookup + deterministic order -# Clear -clearTools() # Empties _registry +# Clear (per-store) +clearTools(store1) # only clears store1 ``` -**Why `OrderedDict` for `getTools()`?** The internal `_registry` is a `Vector{agentTool}` for ordered iteration (used by `listTools`). `getTools()` builds an `OrderedDict` from `_registry` so callers get: +**Why `OrderedDict` for `getTools()`?** The internal `store.tools` is a `Vector{agentTool}` for ordered iteration (used by `listTool`). `getTools()` builds an `OrderedDict` from `store.tools` so callers get: - O(1) lookup by tool name -- Deterministic iteration order (registration order: `listTools` auto-registered first, then tools loaded alphabetically by filename) +- Deterministic iteration order (registration order) - Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) +### Per-Agent Isolation + +Each `ToolStore` is completely independent — tools registered in one store do not appear in another: + +```julia +storeA = ToolStore(name="A") +storeB = ToolStore(name="B") + +registerTool(storeA, getTime_tool) +registerTool(storeB, getWeather_tool) + +getTools(storeA) # only contains getTime +getTools(storeB) # only contains getWeather + +clearTools(storeA) # storeB is unaffected +``` + +This ensures that `yiemAgent` instances with different `tool_store` references operate with completely isolated tool sets. + --- ## 4. The Agent Loop — High-Level Flow @@ -185,6 +217,7 @@ yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() + - _tool_store (ToolStore) ← per-agent isolated tool registry ``` ### Loop States @@ -1083,11 +1116,11 @@ The framework supports tools that modify the tool system itself at runtime. 4. Appends `getTool()` returning an `agentTool` struct 5. Writes the combined string to `src/tools/.jl` -### `listTools` — Discover Available Tools +### `listTool` — Discover Available Tools -**Source:** `tools/registry.jl:24-52` +**Source:** `tools/registry.jl:54-82` -Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. +Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow @@ -1101,7 +1134,7 @@ Returns all registered tools. Primarily useful for **collision detection** befor - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - (optional) validateCode, prepareCode 3. writeTool generates src/tools/searchWine.jl -4. Agent restarts (or hot-reloads) → loadTools("src/tools") picks up the new file +4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file 5. Agent calls searchWine(query="cabernet") 6. Result: "Found 5 cabernet wines..." ``` @@ -1362,7 +1395,7 @@ module _tool_myTool end ``` -All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive in `_tool_modules` to prevent garbage collection of closures. +All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive in `store.modules` to prevent garbage collection of closures. --- diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 0884853..4315211 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -1,27 +1,57 @@ module toolRegistry -export loadTools, registerTool, getTools, clearTools +export ToolStore, loadTools, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures using ..type -# Global registry — populated at runtime by loadTools() or registerTool() -const _registry = Vector{agentTool}() +""" +Per-agent isolated tool storage. -# Module references — kept alive to prevent GC of tool code that closures depend on -const _tool_modules = Vector{Module}() +Each agent gets its own `ToolStore` so tool registration is independent — +`registerTool(store, tool)` only affects that agent's tool set. -# Auto-register the built-in listTools tool -function __init__() - registerTool(_listTool()) +# Fields +- `tools::Vector{agentTool}` — ordered tool list (for `listTool` iteration) +- `modules::Vector{Module}` — keeps tool submodules alive to prevent GC of closures +- `name::String` — identifier for debugging/logs +""" +struct ToolStore + tools::Vector{agentTool} + modules::Vector{Module} + name::String +end + +""" +Create a new isolated tool store. + +# Keyword Arguments +- `name::String`: Identifier for this store (default: "default") + +# Examples +```julia +store = ToolStore(name="agent1") +tools = loadTools(store, "src/tools") +registerTool(store, my_tool) +agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) +``` +""" +function ToolStore(; name::String="default")::ToolStore + ToolStore(agentTool[], Module[], name) end """ List tool definition — lets the agent query available tools for collision detection when creating new tools via writeTool. + +# Arguments +- `store::ToolStore`: The tool store to list from + +Each `ToolStore` gets its own `listTool` instance bound to that store, +so each agent sees only its own tools. """ -function _listTool()::agentTool +function listTool(store::ToolStore)::agentTool return agentTool( name = "listTools", label = "List Tools", @@ -32,7 +62,7 @@ function _listTool()::agentTool "required" => Any[] ), execute = (toolCallId, args, signal, onPartialResult) -> begin - tools = getTools() + tools = getTools(store) if isempty(tools) result_text = "No tools registered." else @@ -52,47 +82,37 @@ function _listTool()::agentTool end """ -Load all tool modules from a directory. +Load all tool modules from a directory into a specific ToolStore. Scans `dir` for `.jl` files. Each file must define a function named -`getTool()::agentTool`. Files are sorted alphabetically so tool +`getTool()::agentTool`. Files are sorted alphabetically so tool registration order is deterministic. Each `.jl` file is loaded into its own **submodule** so that all functions defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, and any helper functions) are namespaced and never collide with other tools. -# Tool file format -Each `.jl` file defines one function `getTool()` that returns an `agentTool`. -Inside the file you can freely define as many helper functions as you need — -they will all be scoped under the tool's submodule. - -```julia -# src/tools/getWeather.jl - -# These are namespaced — no collision with getTime.validateRequiredArgs, etc. -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - ... -end - -function getTool()::agentTool - return agentTool( - name = "getWeather", - ... - ) -end -``` - # Arguments +- `store::ToolStore`: The tool store to register tools into - `dir::String`: Directory path to scan for `.jl` tool files # Returns -- `Vector{agentTool}`: All loaded tools +- `OrderedDict{String, agentTool}`: All loaded tools keyed by name # Errors - Throws `ArgumentError` if a tool file does not define a `getTool` function + +# Examples +```julia +julia> store = ToolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +OrderedDict{String, agentTool} with 3 entries: + "getWeather" => agentTool(...) + "getTime" => agentTool(...) + "listTools" => agentTool(...) +``` """ -function loadTools(dir::String)::OrderedDict{String, agentTool} +function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end @@ -141,10 +161,10 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} # Keep module reference alive — closures in the agentTool (execute, # validateRequiredArgs, prepareArguments) may reference module-scoped # functions. Without this, GC could collect the module. - push!(_tool_modules, mod) - push!(_registry, tool) + push!(store.modules, mod) + push!(store.tools, tool) tools[tool.name] = tool - println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") + println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))") catch e if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) throw(ArgumentError( @@ -160,42 +180,75 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} end """ -Register a single agentTool into the global registry. +Register a single agentTool into a specific ToolStore. # Arguments +- `store::ToolStore`: The tool store to register into - `tool::agentTool`: The tool to register # Returns -- `Vector{agentTool}`: Updated registry +- `Vector{agentTool}`: Updated tool list for this store + +# Examples +```julia +julia> store = ToolStore(name="agent1") +julia> registerTool(store, my_tool) +[toolRegistry:agent1] Registered tool: my_tool +``` """ -function registerTool(tool::agentTool)::Vector{agentTool} - push!(_registry, tool) - println("[toolRegistry] Registered tool: $(tool.name)") - return _registry +function registerTool(store::ToolStore, tool::agentTool)::Vector{agentTool} + push!(store.tools, tool) + println("[$(store.name)] Registered tool: $(tool.name)") + return store.tools end """ -Get all registered tools as an `OrderedDict{String, agentTool}` keyed by tool name. +Get all registered tools from a specific ToolStore as an +`OrderedDict{String, agentTool}` keyed by tool name. -The internal `_registry` is a `Vector` for ordered iteration (used by `listTools`). -This function builds an `OrderedDict` from `_registry` so callers get: +The internal vector is for ordered iteration (used by `listTool`). +This function builds an `OrderedDict` so callers get: - O(1) lookup by name -- Deterministic iteration order (registration order: alphabetical by filename) +- Deterministic iteration order (registration order) - Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) +# Arguments +- `store::ToolStore`: The tool store to query + # Returns -- `OrderedDict{String, agentTool}`: Copy of the registry keyed by tool name, in registration order +- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order + +# Examples +```julia +julia> getTools(store) +OrderedDict{String, agentTool} with 3 entries: + "listTools" => agentTool(...) + "getWeather" => agentTool(...) + "getTime" => agentTool(...) +``` """ -function getTools()::OrderedDict{String, agentTool} - return OrderedDict{String, agentTool}(t.name => t for t in _registry) +function getTools(store::ToolStore)::OrderedDict{String, agentTool} + return OrderedDict{String, agentTool}(t.name => t for t in store.tools) end """ -Clear all registered tools from the global registry. +Clear all registered tools from a specific ToolStore. + +# Arguments +- `store::ToolStore`: The tool store to clear + +# Returns +- `nothing` + +# Examples +```julia +julia> clearTools(store) +[toolRegistry:agent1] Registry cleared +``` """ -function clearTools()::Nothing - empty!(_registry) - println("[toolRegistry] Registry cleared") +function clearTools(store::ToolStore)::Nothing + empty!(store.tools) + println("[$(store.name)] Registry cleared") return nothing end diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index 73dc6da..c682e1d 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -7,7 +7,7 @@ The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so `loadTools("src/tools")` picks +After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks up the new file. The new tool is immediately available. # Example @@ -250,13 +250,13 @@ function getTool()::agentTool tool_code = join(parts) - # Write the file — tool is loaded on next agent restart via loadTools() + # Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools") write(filepath, tool_code) onPartialResult(Dict("status" => "Done")) return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")], Dict{Any,Any}( "file" => filepath, "name" => tool_name, diff --git a/src/type.jl b/src/type.jl index ee373cf..37b614f 100644 --- a/src/type.jl +++ b/src/type.jl @@ -567,9 +567,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function -end + parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function + _tool_store::Any # Reference to the ToolStore for runtime registration + end """ Create a new yiemAgent instance with a background loop task. @@ -593,15 +594,17 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `agentEventSink::Function`: Callback to receive agent events +- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task # Examples ```julia -julia> tools = loadTools("src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=...) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) +julia> store = ToolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) """ function yiemAgent( ; systemPrompt::String="You are helpful assistant.", @@ -619,6 +622,7 @@ function yiemAgent( maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, agentEventSink::Function, + tool_store::Union{Any, Nothing}=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -643,6 +647,7 @@ function yiemAgent( maxRetryDelayMs, parallelToolExecute, agentEventSink, + tool_store, ) # Spawn the background loop and attach it diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index f0f10df..1c16faf 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -6,12 +6,13 @@ using YiemAgent.type # Path to the real tools directory TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") -@testset "loadTools" begin +@testset "loadTools with ToolStore" begin # ------------------------------------------------------------------ # # 1. loadTools throws on non-existent directory # # ------------------------------------------------------------------ # - @test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist") + store = ToolStore(name="test1") + @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") # ------------------------------------------------------------------ # # 2. loadTools throws if a .jl file does not define getTool() # @@ -20,12 +21,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # bad_dir = mktempdir() write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") - @test_throws ArgumentError loadTools(bad_dir) + @test_throws ArgumentError loadTools(store, bad_dir) # ------------------------------------------------------------------ # # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - loaded = loadTools(TOOLS_DIR) + store2 = ToolStore(name="test2") + loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) @test length(loaded) == 3 @@ -98,20 +100,29 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test occursin("72°F", result_w2.content[1].text) # ------------------------------------------------------------------ # - # 7. getTools / registerTool / clearTools # + # 7. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # - registry_tools = getTools() - @test !isempty(registry_tools) - @test "getTime" in keys(registry_tools) - @test "getWeather" in keys(registry_tools) - # listTools is auto-registered via __init__() → first key, then tools loaded alphabetically - @test collect(keys(registry_tools))[1] == "listTools" - @test collect(keys(registry_tools))[2] == "getTime" - @test collect(keys(registry_tools))[3] == "getWeather" - @test collect(keys(registry_tools))[4] == "writeTool" + store3 = ToolStore(name="test3") + registry_tools = getTools(store3) + @test isempty(registry_tools) - clearTools() - @test isempty(getTools()) + # listTool is not auto-registered anymore — each store starts empty + # Register tools manually + registerTool(store3, loaded["getTime"]) + registerTool(store3, loaded["getWeather"]) + registerTool(store3, loaded["writeTool"]) + + reg = getTools(store3) + @test !isempty(reg) + @test "getTime" in keys(reg) + @test "getWeather" in keys(reg) + @test "writeTool" in keys(reg) + @test collect(keys(reg))[1] == "getTime" + @test collect(keys(reg))[2] == "getWeather" + @test collect(keys(reg))[3] == "writeTool" + + clearTools(store3) + @test isempty(getTools(store3)) test_tool = agentTool( name = "manualTool", @@ -124,8 +135,8 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") validateRequiredArgs = nothing, parallelToolExecute = true ) - registerTool(test_tool) - reg = getTools() + registerTool(store3, test_tool) + reg = getTools(store3) @test haskey(reg, "manualTool") @test length(reg) == 1 @test reg["manualTool"].parallelToolExecute == true @@ -133,9 +144,30 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 8. getTools returns deep copy (mutations don't affect registry) # # ------------------------------------------------------------------ # - copy1 = getTools() - copy2 = getTools() + copy1 = getTools(store3) + copy2 = getTools(store3) @test copy1 !== copy2 empty!(copy1) - @test !isempty(getTools()) + @test !isempty(getTools(store3)) + + # ------------------------------------------------------------------ # + # 9. Per-store isolation — two stores don't share tools # + # ------------------------------------------------------------------ # + storeA = ToolStore(name="isolationA") + storeB = ToolStore(name="isolationB") + + registerTool(storeA, loaded["getTime"]) + registerTool(storeB, loaded["getWeather"]) + + regA = getTools(storeA) + regB = getTools(storeB) + + @test "getTime" in keys(regA) + @test "getWeather" ∉ keys(regA) + @test "getWeather" in keys(regB) + @test "getTime" ∉ keys(regB) + + clearTools(storeA) + @test isempty(getTools(storeA)) + @test !isempty(getTools(storeB)) # storeB unaffected end -- 2.52.0 From c5cb18f0f14cd2d0125870449a0c0c8fc78eb0b8 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 16:10:04 +0700 Subject: [PATCH 066/112] update --- src/YiemAgent.jl | 2 +- src/{tools/registry.jl => toolRegistry.jl} | 38 ++++++++-------------- src/tools/README.md | 23 ++++++------- test/{loadToolTest.jl => toolTest.jl} | 6 ++-- 4 files changed, 29 insertions(+), 40 deletions(-) rename src/{tools/registry.jl => toolRegistry.jl} (84%) rename test/{loadToolTest.jl => toolTest.jl} (97%) diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 765807e..5659d95 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -13,7 +13,7 @@ module YiemAgent include("utils.jl") using .utils - include("tools/registry.jl") + include("toolRegistry.jl") using .toolRegistry # include("llmfunction.jl") diff --git a/src/tools/registry.jl b/src/toolRegistry.jl similarity index 84% rename from src/tools/registry.jl rename to src/toolRegistry.jl index 4315211..8cf9a45 100644 --- a/src/tools/registry.jl +++ b/src/toolRegistry.jl @@ -13,13 +13,11 @@ Each agent gets its own `ToolStore` so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. # Fields -- `tools::Vector{agentTool}` — ordered tool list (for `listTool` iteration) -- `modules::Vector{Module}` — keeps tool submodules alive to prevent GC of closures +- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration - `name::String` — identifier for debugging/logs """ struct ToolStore - tools::Vector{agentTool} - modules::Vector{Module} + tools::OrderedDict{String, agentTool} name::String end @@ -38,7 +36,7 @@ agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) ``` """ function ToolStore(; name::String="default")::ToolStore - ToolStore(agentTool[], Module[], name) + ToolStore(OrderedDict{String, agentTool}(), name) end """ @@ -66,7 +64,7 @@ function listTool(store::ToolStore)::agentTool if isempty(tools) result_text = "No tools registered." else - lines = String["- $(t.name): $(t.label) — $(t.description)" for t in tools] + lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools] result_text = "Available tools:\n" * join(lines, "\n") end return agentToolResult( @@ -117,7 +115,6 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool throw(ArgumentError("Tool directory does not exist: $dir")) end - tools = OrderedDict{String, agentTool}() jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) sort!(jl_files) @@ -158,12 +155,7 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" )) end - # Keep module reference alive — closures in the agentTool (execute, - # validateRequiredArgs, prepareArguments) may reference module-scoped - # functions. Without this, GC could collect the module. - push!(store.modules, mod) - push!(store.tools, tool) - tools[tool.name] = tool + store.tools[tool.name] = tool println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))") catch e if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) @@ -176,7 +168,7 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool end end - return tools + return store.tools end """ @@ -187,7 +179,7 @@ Register a single agentTool into a specific ToolStore. - `tool::agentTool`: The tool to register # Returns -- `Vector{agentTool}`: Updated tool list for this store +- `OrderedDict{String, agentTool}`: Updated tool dict for this store # Examples ```julia @@ -196,21 +188,17 @@ julia> registerTool(store, my_tool) [toolRegistry:agent1] Registered tool: my_tool ``` """ -function registerTool(store::ToolStore, tool::agentTool)::Vector{agentTool} - push!(store.tools, tool) +function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool} + store.tools[tool.name] = tool println("[$(store.name)] Registered tool: $(tool.name)") return store.tools end """ -Get all registered tools from a specific ToolStore as an -`OrderedDict{String, agentTool}` keyed by tool name. +Get the registered tools from a specific ToolStore. -The internal vector is for ordered iteration (used by `listTool`). -This function builds an `OrderedDict` so callers get: -- O(1) lookup by name -- Deterministic iteration order (registration order) -- Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) +Returns the internal `OrderedDict` directly — O(1) lookup by name, +ordered iteration preserving registration order. # Arguments - `store::ToolStore`: The tool store to query @@ -228,7 +216,7 @@ OrderedDict{String, agentTool} with 3 entries: ``` """ function getTools(store::ToolStore)::OrderedDict{String, agentTool} - return OrderedDict{String, agentTool}(t.name => t for t in store.tools) + return store.tools end """ diff --git a/src/tools/README.md b/src/tools/README.md index 0f4cc43..c798e03 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -112,7 +112,7 @@ The `terminate` flag is checked at the batch level. See [Section 9](#9-tool-call ## 3. Tool Registration — Per-Agent Tool Stores -**Source:** `tools/registry.jl` +**Source:** `toolRegistry.jl` ### How `ToolStore` Works @@ -120,19 +120,20 @@ The registry uses **per-agent isolated storage** via the `ToolStore` struct. Eac ```julia struct ToolStore - tools::Vector{agentTool} # ordered tool list (for listTool iteration) - modules::Vector{Module} # keeps tool submodules alive to prevent GC - name::String # identifier for debugging/logs + tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration + name::String # identifier for debugging/logs end ``` +`store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. + ### How `loadTools(store, dir)` Works ```julia function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} ``` -**Source:** `tools/registry.jl:115-180` +**Source:** `toolRegistry.jl:113-177` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order @@ -147,15 +148,14 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool ``` 4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues 5. **Validates** the return value is an `agentTool` instance -6. **Stores** the module reference in `store.modules` to prevent GC of closures -7. **Registers** the tool in `store.tools` and returns an `OrderedDict{String, agentTool}` +6. **Registers** the tool in `store.tools` and returns an `OrderedDict{String, agentTool}` ### Why Submodules? Each tool file is loaded into its own **namespaced submodule**. This means: - `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` - No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` -- The module reference is kept alive in `store.modules` so closures (in `execute`, `validateRequiredArgs`, `prepareArguments`) don't get garbage collected +- The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected ### Registration API @@ -178,10 +178,11 @@ all_tools = getTools(store1) # OrderedDict{String, agentTool} — O(1) lookup + clearTools(store1) # only clears store1 ``` -**Why `OrderedDict` for `getTools()`?** The internal `store.tools` is a `Vector{agentTool}` for ordered iteration (used by `listTool`). `getTools()` builds an `OrderedDict` from `store.tools` so callers get: +`getTools(store)` returns the internal `OrderedDict` directly, giving callers: - O(1) lookup by tool name - Deterministic iteration order (registration order) - Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) +- No copy overhead — mutations on the returned value affect the store ### Per-Agent Isolation @@ -1118,7 +1119,7 @@ The framework supports tools that modify the tool system itself at runtime. ### `listTool` — Discover Available Tools -**Source:** `tools/registry.jl:54-82` +**Source:** `toolRegistry.jl:54-82` Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. @@ -1395,7 +1396,7 @@ module _tool_myTool end ``` -All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive in `store.modules` to prevent garbage collection of closures. +All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive by the function objects stored in `agentTool`, preventing garbage collection of closures. --- diff --git a/test/loadToolTest.jl b/test/toolTest.jl similarity index 97% rename from test/loadToolTest.jl rename to test/toolTest.jl index 1c16faf..8057cf1 100644 --- a/test/loadToolTest.jl +++ b/test/toolTest.jl @@ -142,13 +142,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test reg["manualTool"].parallelToolExecute == true # ------------------------------------------------------------------ # - # 8. getTools returns deep copy (mutations don't affect registry) # + # 8. getTools returns direct reference (mutations affect registry) # # ------------------------------------------------------------------ # copy1 = getTools(store3) copy2 = getTools(store3) - @test copy1 !== copy2 + @test copy1 === copy2 # same reference, not a deep copy empty!(copy1) - @test !isempty(getTools(store3)) + @test isempty(getTools(store3)) # mutation propagates # ------------------------------------------------------------------ # # 9. Per-store isolation — two stores don't share tools # -- 2.52.0 From a9fa23f01b5e80c37b5d20f6695b3423814dd7bf Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 19:19:58 +0700 Subject: [PATCH 067/112] update --- src/tools/README.md | 296 ++++++++++++++++++++++++++++++-------------- 1 file changed, 202 insertions(+), 94 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index c798e03..ea6a6e4 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -6,26 +6,135 @@ This document describes the complete tool lifecycle in the YiemAgent framework, ## Table of Contents -1. [Overview](#1-overview) -2. [Tool Definition — The `agentTool` Struct](#2-tool-definition--the-agenttool-struct) -3. [Tool Registration — Per-Agent Tool Stores](#3-tool-registration--per-agent-tool-stores) -4. [The Agent Loop — High-Level Flow](#4-the-agent-loop--high-level-flow) -5. [Message Processing Pipeline](#5-message-processing-pipeline) -6. [Tool Call Extraction from LLM Response](#6-tool-call-extraction-from-llm-response) -7. [The Per-Call Pipeline — Prepare, Execute, Finalize](#7-the-per-call-pipeline--prepare-execute-finalize) -8. [Execution Modes — Sequential vs Parallel](#8-execution-modes--sequential-vs-parallel) -9. [Tool Call Batches & Termination Logic](#9-tool-call-batches--termination-logic) -10. [Tool Result Message Creation](#10-tool-result-message-creation) -11. [Error Handling & Recovery Pattern](#11-error-handling--recovery-pattern) -12. [Event System — Tool Lifecycle Events](#12-event-system--tool-lifecycle-events) -13. [Agent Lifecycle Hooks](#13-agent-lifecycle-hooks) -14. [Self-Modifying Tools](#14-self-modifying-tools) -15. [Complete End-to-End Example](#15-complete-end-to-end-example) -16. [Tool File Contract](#16-tool-file-contract) +1. [Quick Start: Four-Step Tool Lifecycle](#1-quick-start-four-step-tool-lifecycle) +2. [Overview](#2-overview) +3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) +4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores) +5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) +6. [Message Processing Pipeline](#6-message-processing-pipeline) +7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) +8. [The Per-Call Pipeline — Prepare, Execute, Finalize](#8-the-per-call-pipeline--prepare-execute-finalize) +9. [Execution Modes — Sequential vs Parallel](#9-execution-modes--sequential-vs-parallel) +10. [Tool Call Batches & Termination Logic](#10-tool-call-batches--termination-logic) +11. [Tool Result Message Creation](#11-tool-result-message-creation) +12. [Error Handling & Recovery Pattern](#12-error-handling--recovery-pattern) +13. [Event System — Tool Lifecycle Events](#13-event-system--tool-lifecycle-events) +14. [Agent Lifecycle Hooks](#14-agent-lifecycle-hooks) +15. [Self-Modifying Tools](#15-self-modifying-tools) +16. [Complete End-to-End Example](#16-complete-end-to-end-example) +17. [Tool File Contract](#17-tool-file-contract) +18. [Appendix: Type Reference](#18-appendix-type-reference) --- -## 1. Overview +## 1. Quick Start: Four-Step Tool Lifecycle + +This section shows the complete lifecycle from discovery to result extraction. Each step maps to the detailed sections below. + +### Step 1: Discover — `listTools` + +The agent calls the `listTools` tool to see available tools and detect name collisions before creating new ones. + +```julia +# The listTools tool is auto-injected via listTool(store) — no manual registration needed +tool = listTool(store) # Returns an agentTool that, when executed, lists all tools in the store +``` + +**Result extraction:** +```julia +result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) +# result.content[1].text => "Available tools:\n- getTime: Time Lookup — Get current local time...\n- getWeather: Weather Lookup — Fetch current weather..." +``` + +**Source:** `toolRegistry.jl:54-82` + +--- + +### Step 2: Load — `loadTools()` + +Load all tool modules from a directory into a `ToolStore`. Each `.jl` file must define `getTool()::agentTool`. + +```julia +using YiemAgent, YiemAgent.toolRegistry + +store = ToolStore(name="myAgent") +tools = loadTools(store, "src/tools") +# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools +``` + +**Result extraction:** +```julia +all_tools = getTools(store) # OrderedDict{String, agentTool} +# Keys: "getTime", "getWeather", "writeTool" +getTime_tool = all_tools["getTime"] + +# Manual registration (alternative to loadTools) +registerTool(store, my_tool) +clearTools(store) # Clear all tools from store +``` + +**Source:** `toolRegistry.jl:113-172` + +--- + +### Step 3: Use — Tool Execution + +Tools can be used in two ways: + +**Direct execution (testing / standalone):** +```julia +using YiemAgent.type + +sig = nothing +op = x -> x # no-op partial result callback + +# Execute a loaded tool directly +result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) +``` + +**Via agent loop (production):** +``` +user message → run_agent(agent, Dict("role"=>"user", "content"=>...)) + → _agent_loop detects message → @spawn _process_message(agent) + → prepareContext → formatMsgForLLM → llmCall + → LLM returns tool_calls + → executeToolCalls(context, response, tool_call_list, config, signal, emit) + → prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall + → createToolResultMessage → batch.messages (toolResultMessage[]) +``` + +**Source:** Direct: `test/toolTest.jl:81-99` | Agent: `agentCore.jl:35-311` + +--- + +### Step 4: Extract Result + +**`agentToolResult`** (raw tool output, `type.jl:429-434`): +```julia +result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) + +result.content[1] # textContent("Current time in Tokyo: ...") +result.content[1].text # "Current time in Tokyo: 2026-08-10T..." +result.details # Dict{Any,Any}() — tool-specific metadata +result.usage # nothing — llmUsage tracking (optional) +result.terminate # false — signals loop termination +``` + +**`toolResultMessage`** (wrapped for conversation history, `type.jl:152-191`): +```julia +msg = batch.messages[1] # toolResultMessage + +msg.toolCallId # "call-1" +msg.toolName # "getTime" +msg.content # Vector{messageContent} +msg.isError # false +msg.details # tool-specific metadata +msg.timestamp # DateTime +``` + +--- + +## 2. Overview The tool system follows a **three-phase pipeline** per tool call: @@ -45,7 +154,7 @@ The pipeline ensures that **every tool call produces a result**, even on failure --- -## 2. Tool Definition — The `agentTool` Struct +## 3. Tool Definition — The `agentTool` Struct **Source:** `type.jl:261-281` @@ -106,11 +215,11 @@ struct agentToolResult end ``` -The `terminate` flag is checked at the batch level. See [Section 9](#9-tool-call-batches--termination-logic) for details. +The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-call-batches--termination-logic) for details. --- -## 3. Tool Registration — Per-Agent Tool Stores +## 4. Tool Registration — Per-Agent Tool Stores **Source:** `toolRegistry.jl` @@ -133,7 +242,7 @@ end function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} ``` -**Source:** `toolRegistry.jl:113-177` +**Source:** `toolRegistry.jl:113-172` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order @@ -205,7 +314,7 @@ This ensures that `yiemAgent` instances with different `tool_store` references o --- -## 4. The Agent Loop — High-Level Flow +## 5. The Agent Loop — High-Level Flow **Source:** `agentCore.jl:35-145` @@ -274,7 +383,7 @@ end --- -## 5. Message Processing Pipeline +## 6. Message Processing Pipeline **Source:** `agentCore.jl:175-311` @@ -356,7 +465,7 @@ There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts executio --- -## 6. Tool Call Extraction from LLM Response +## 7. Tool Call Extraction from LLM Response **Source:** `agentCore.jl:217-245` @@ -432,11 +541,11 @@ end --- -## 7. The Per-Call Pipeline — Prepare, Execute, Finalize +## 8. The Per-Call Pipeline — Prepare, Execute, Finalize This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases. -### 7.1 Phase 1: Prepare — `prepareToolCall()` +### 8.1 Phase 1: Prepare — `prepareToolCall()` **Source:** `agentCore.jl:511-547` @@ -477,7 +586,7 @@ function prepareToolCall( **Key design principle:** Preparation **never throws**. Every failure path returns an `immediateOutcome` with `isError=true`, ensuring the agent loop always has a valid result to feed back to the LLM. -### 7.2 Phase 2: Execute — `executePreparedToolCall()` +### 8.2 Phase 2: Execute — `executePreparedToolCall()` **Source:** `agentCore.jl:589-617` @@ -492,46 +601,46 @@ function executePreparedToolCall( **Steps:** 1. **Initialize streaming state:** - ```julia - updateEvents = promise[] # vector to collect update event handles - accepting = true # guard to prevent duplicate emissions - ``` + ```julia + updateEvents = promise[] # vector to collect update event handles + accepting = true # guard to prevent duplicate emissions + ``` 2. **Call `tool.execute()`:** - ```julia - result = prep.tool.execute( - prep.toolCall.id, - prep.args, - signal, - partialResult -> begin - if accepting - push!(updateEvents, - emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - ``` + ```julia + result = prep.tool.execute( + prep.toolCall.id, + prep.args, + signal, + partialResult -> begin + if accepting + push!(updateEvents, + emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + prep.toolCall.arguments, partialResult))) + end + end + ) + ``` 3. **Wait for streaming to settle:** - ```julia - accepting = false - wait.(updateEvents) # wait for all pending update event handlers - return executedOutcome(result, false) - ``` + ```julia + accepting = false + wait.(updateEvents) # wait for all pending update event handlers + return executedOutcome(result, false) + ``` 4. **On error:** - ```julia - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end - ``` + ```julia + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end + ``` **Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted. -### 7.3 Phase 3: Finalize — `finalizeExecutedToolCall()` +### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` **Source:** `agentCore.jl:675-706` @@ -542,44 +651,44 @@ function finalizeExecutedToolCall( prep::preparedToolCall, executed::executedOutcome, config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + signal::Union{Nothing,abortSignal}, )::finalizedOutcome ``` **Steps:** 1. **Extract execution result:** - ```julia - result = executed.result - isError = executed.isError - ``` + ```julia + result = executed.result + isError = executed.isError + ``` 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - - Hook can mutate the result: - ```julia - after = config.afterToolCall(afterCtx(...)) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - ``` - - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic - - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` + - Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Hook can mutate the result: + ```julia + after = config.afterToolCall(afterCtx(...)) + if after !== nothing + result = merge(result, dict( + :content => get(after, :content, result.content), + :details => get(after, :details, result.details), + :usage => get(after, :usage, result.usage), + :terminate => get(after, :terminate, result.terminate) + )) + isError = get(after, :isError, isError) + end + ``` + - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic + - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` 3. **Return:** - ```julia - return finalizedOutcome(prep.toolCall, result, isError) - ``` + ```julia + return finalizedOutcome(prep.toolCall, result, isError) + ``` **Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. -### 7.4 Emission — `emitToolExecutionEnd()` +### 8.4 Emission — `emitToolExecutionEnd()` **Source:** `agentCore.jl:736-739` @@ -594,7 +703,7 @@ This is called immediately after finalization, before building the `toolResultMe --- -## 8. Execution Modes — Sequential vs Parallel +## 9. Execution Modes — Sequential vs Parallel **Source:** `agentCore.jl:795-936, 988-1011` @@ -721,7 +830,7 @@ end --- -## 9. Tool Call Batches & Termination Logic +## 10. Tool Call Batches & Termination Logic **Source:** `type.jl:793-838` @@ -803,7 +912,7 @@ end --- -## 10. Tool Result Message Creation +## 11. Tool Result Message Creation **Source:** `agentCore.jl:373-379` @@ -864,7 +973,7 @@ Dict( --- -## 11. Error Handling & Recovery Pattern +## 12. Error Handling & Recovery Pattern The framework uses a **result-based error handling** pattern instead of exceptions for tool call failures. This ensures the LLM always receives a tool result message, giving it the information to recover. @@ -912,7 +1021,7 @@ Returns an `agentToolResult` with a single `textContent` block containing the er --- -## 12. Event System — Tool Lifecycle Events +## 13. Event System — Tool Lifecycle Events **Source:** `type.jl:480-516` @@ -970,7 +1079,7 @@ The `agentEventSink` function is a user-provided callback that receives all even --- -## 13. Agent Lifecycle Hooks +## 14. Agent Lifecycle Hooks ### Hook Types @@ -1101,7 +1210,7 @@ Override this to produce custom LLM message formats for different APIs/providers --- -## 14. Self-Modifying Tools +## 15. Self-Modifying Tools The framework supports tools that modify the tool system itself at runtime. @@ -1110,7 +1219,6 @@ The framework supports tools that modify the tool system itself at runtime. **Source:** `tools/writeTool.jl` `writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (Julia code body), and `writeTool` wraps it in Julia boilerplate: - 1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal 2. Indents `executeCode` with 4 spaces 3. Wraps it inside `function executeTool(...)::agentToolResult ... end` @@ -1155,7 +1263,7 @@ Each `ToolStore` gets its own `listTool` instance bound to that store via `listT --- -## 15. Complete End-to-End Example +## 16. Complete End-to-End Example ### Full Lifecycle: User Message to Tool Result @@ -1269,7 +1377,7 @@ AGENT LOOP: SEND RESPONSE TO USER --- -## 16. Tool File Contract +## 17. Tool File Contract Each `.jl` file in `src/tools/` must conform to the following contract: @@ -1400,7 +1508,7 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli --- -## Appendix: Type Reference +## 18. Appendix: Type Reference ### Message Types -- 2.52.0 From 287704778f4539d533682a3a6660f2392bbab51a Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 20:07:15 +0700 Subject: [PATCH 068/112] update --- src/tools/README.md | 61 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index ea6a6e4..de6986f 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -6,7 +6,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework, ## Table of Contents -1. [Quick Start: Four-Step Tool Lifecycle](#1-quick-start-four-step-tool-lifecycle) +1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) 2. [Overview](#2-overview) 3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) 4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores) @@ -27,9 +27,9 @@ This document describes the complete tool lifecycle in the YiemAgent framework, --- -## 1. Quick Start: Four-Step Tool Lifecycle +## 1. Quick Start: Tool Lifecycle -This section shows the complete lifecycle from discovery to result extraction. Each step maps to the detailed sections below. +This section shows the complete lifecycle from tool registration through execution and result extraction. Each step maps to the detailed sections below. ### Step 1: Discover — `listTools` @@ -77,6 +77,61 @@ clearTools(store) # Clear all tools from store --- +### Step 2.5: Create Agent with Tools + +Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is deep-copied into `agent._state.tools`; `_tool_store` is kept for runtime registration. + +```julia +using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry + +# 1. Set up ToolStore and load tools +store = ToolStore(name="myAgent") +loadTools(store, "src/tools") + +# 2. Create agent — pass tools + _tool_store +agent = yiemAgent( + systemPrompt = "You are a helpful assistant.", + model = my_model, + tools = getTools(store), # OrderedDict{String, agentTool} + llmCall = my_llm_call, # Function that calls the LLM API + agentEventSink = my_event_sink, # Function for TUI/logging + _tool_store = store, # For runtime registerTool() calls +) +``` + +**Manual registration** (without `loadTools`): + +```julia +store = ToolStore(name="myAgent") +registerTool(store, getTime_tool) +registerTool(store, getWeather_tool) + +agent = yiemAgent( + tools = getTools(store), + llmCall = my_llm_call, + agentEventSink = my_event_sink, + _tool_store = store, +) +``` + +**Key constructor parameters:** + +| Parameter | Type | Required | Purpose | +|-----------|------|----------|---------| +| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | +| `model` | `llmModel` | No | LLM model config | +| `tools` | `OrderedDict{String, agentTool}` | No | Available tools (deep-copied) | +| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | +| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | +| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | +| `_tool_store` | `ToolStore` | No | Runtime tool registry for `registerTool()` | + +Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. + +**Source:** `type.jl:609-657`, `toolRegistry.jl:38-40, 191-195` + +--- + ### Step 3: Use — Tool Execution Tools can be used in two ways: -- 2.52.0 From ed9126046835e0e5d9c4c3df5135040747a812ef Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 20:37:28 +0700 Subject: [PATCH 069/112] update --- src/toolRegistry.jl | 46 ++++++++++++++++++++++----------------------- src/tools/README.md | 34 ++++++++++++++++----------------- src/type.jl | 6 +++--- test/toolTest.jl | 12 ++++++------ 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 8cf9a45..3d786b3 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,6 +1,6 @@ module toolRegistry -export ToolStore, loadTools, registerTool, getTools, clearTools, listTool +export toolStore, loadTools, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures @@ -9,14 +9,14 @@ using ..type """ Per-agent isolated tool storage. -Each agent gets its own `ToolStore` so tool registration is independent — +Each agent gets its own `toolStore` so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. # Fields - `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration - `name::String` — identifier for debugging/logs """ -struct ToolStore +struct toolStore tools::OrderedDict{String, agentTool} name::String end @@ -29,14 +29,14 @@ Create a new isolated tool store. # Examples ```julia -store = ToolStore(name="agent1") +store = toolStore(name="agent1") tools = loadTools(store, "src/tools") registerTool(store, my_tool) agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) ``` """ -function ToolStore(; name::String="default")::ToolStore - ToolStore(OrderedDict{String, agentTool}(), name) +function toolStore(; name::String="default")::toolStore + toolStore(OrderedDict{String, agentTool}(), name) end """ @@ -44,12 +44,12 @@ List tool definition — lets the agent query available tools for collision dete when creating new tools via writeTool. # Arguments -- `store::ToolStore`: The tool store to list from +- `store::toolStore`: The tool store to list from -Each `ToolStore` gets its own `listTool` instance bound to that store, +Each `toolStore` gets its own `listTool` instance bound to that store, so each agent sees only its own tools. """ -function listTool(store::ToolStore)::agentTool +function listTool(store::toolStore)::agentTool return agentTool( name = "listTools", label = "List Tools", @@ -80,7 +80,7 @@ function listTool(store::ToolStore)::agentTool end """ -Load all tool modules from a directory into a specific ToolStore. +Load all tool modules from a directory into a specific toolStore. Scans `dir` for `.jl` files. Each file must define a function named `getTool()::agentTool`. Files are sorted alphabetically so tool @@ -91,7 +91,7 @@ defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, and any helper functions) are namespaced and never collide with other tools. # Arguments -- `store::ToolStore`: The tool store to register tools into +- `store::toolStore`: The tool store to register tools into - `dir::String`: Directory path to scan for `.jl` tool files # Returns @@ -102,7 +102,7 @@ and any helper functions) are namespaced and never collide with other tools. # Examples ```julia -julia> store = ToolStore(name="agent1") +julia> store = toolStore(name="agent1") julia> tools = loadTools(store, "src/tools") OrderedDict{String, agentTool} with 3 entries: "getWeather" => agentTool(...) @@ -110,7 +110,7 @@ OrderedDict{String, agentTool} with 3 entries: "listTools" => agentTool(...) ``` """ -function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} +function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end @@ -172,10 +172,10 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool end """ -Register a single agentTool into a specific ToolStore. +Register a single agentTool into a specific toolStore. # Arguments -- `store::ToolStore`: The tool store to register into +- `store::toolStore`: The tool store to register into - `tool::agentTool`: The tool to register # Returns @@ -183,25 +183,25 @@ Register a single agentTool into a specific ToolStore. # Examples ```julia -julia> store = ToolStore(name="agent1") +julia> store = toolStore(name="agent1") julia> registerTool(store, my_tool) [toolRegistry:agent1] Registered tool: my_tool ``` """ -function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool} +function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool} store.tools[tool.name] = tool println("[$(store.name)] Registered tool: $(tool.name)") return store.tools end """ -Get the registered tools from a specific ToolStore. +Get the registered tools from a specific toolStore. Returns the internal `OrderedDict` directly — O(1) lookup by name, ordered iteration preserving registration order. # Arguments -- `store::ToolStore`: The tool store to query +- `store::toolStore`: The tool store to query # Returns - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order @@ -215,15 +215,15 @@ OrderedDict{String, agentTool} with 3 entries: "getTime" => agentTool(...) ``` """ -function getTools(store::ToolStore)::OrderedDict{String, agentTool} +function getTools(store::toolStore)::OrderedDict{String, agentTool} return store.tools end """ -Clear all registered tools from a specific ToolStore. +Clear all registered tools from a specific toolStore. # Arguments -- `store::ToolStore`: The tool store to clear +- `store::toolStore`: The tool store to clear # Returns - `nothing` @@ -234,7 +234,7 @@ julia> clearTools(store) [toolRegistry:agent1] Registry cleared ``` """ -function clearTools(store::ToolStore)::Nothing +function clearTools(store::toolStore)::Nothing empty!(store.tools) println("[$(store.name)] Registry cleared") return nothing diff --git a/src/tools/README.md b/src/tools/README.md index de6986f..f981e3c 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -52,12 +52,12 @@ result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) ### Step 2: Load — `loadTools()` -Load all tool modules from a directory into a `ToolStore`. Each `.jl` file must define `getTool()::agentTool`. +Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. ```julia using YiemAgent, YiemAgent.toolRegistry -store = ToolStore(name="myAgent") +store = toolStore(name="myAgent") tools = loadTools(store, "src/tools") # Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools ``` @@ -84,8 +84,8 @@ Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry -# 1. Set up ToolStore and load tools -store = ToolStore(name="myAgent") +# 1. Set up toolStore and load tools +store = toolStore(name="myAgent") loadTools(store, "src/tools") # 2. Create agent — pass tools + _tool_store @@ -102,7 +102,7 @@ agent = yiemAgent( **Manual registration** (without `loadTools`): ```julia -store = ToolStore(name="myAgent") +store = toolStore(name="myAgent") registerTool(store, getTime_tool) registerTool(store, getWeather_tool) @@ -124,7 +124,7 @@ agent = yiemAgent( | `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | | `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | -| `_tool_store` | `ToolStore` | No | Runtime tool registry for `registerTool()` | +| `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` | Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. @@ -278,12 +278,12 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca **Source:** `toolRegistry.jl` -### How `ToolStore` Works +### How `toolStore` Works -The registry uses **per-agent isolated storage** via the `ToolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. +The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. ```julia -struct ToolStore +struct toolStore tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration name::String # identifier for debugging/logs end @@ -294,7 +294,7 @@ end ### How `loadTools(store, dir)` Works ```julia -function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} +function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} ``` **Source:** `toolRegistry.jl:113-172` @@ -325,8 +325,8 @@ Each tool file is loaded into its own **namespaced submodule**. This means: ```julia # Create per-agent stores -store1 = ToolStore(name="agent1") -store2 = ToolStore(name="agent2") +store1 = toolStore(name="agent1") +store2 = toolStore(name="agent2") # Load tools into specific stores tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only @@ -350,11 +350,11 @@ clearTools(store1) # only clears store1 ### Per-Agent Isolation -Each `ToolStore` is completely independent — tools registered in one store do not appear in another: +Each `toolStore` is completely independent — tools registered in one store do not appear in another: ```julia -storeA = ToolStore(name="A") -storeB = ToolStore(name="B") +storeA = toolStore(name="A") +storeB = toolStore(name="B") registerTool(storeA, getTime_tool) registerTool(storeB, getWeather_tool) @@ -382,7 +382,7 @@ yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() - - _tool_store (ToolStore) ← per-agent isolated tool registry + - _tool_store (toolStore) ← per-agent isolated tool registry ``` ### Loop States @@ -1284,7 +1284,7 @@ The framework supports tools that modify the tool system itself at runtime. **Source:** `toolRegistry.jl:54-82` -Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. +Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow diff --git a/src/type.jl b/src/type.jl index 37b614f..f01b623 100644 --- a/src/type.jl +++ b/src/type.jl @@ -569,7 +569,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false agentEventSink::Function # agent emits its status via this function - _tool_store::Any # Reference to the ToolStore for runtime registration + _tool_store::Any # Reference to the toolStore for runtime registration end """ @@ -594,14 +594,14 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `agentEventSink::Function`: Callback to receive agent events -- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`) +- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task # Examples ```julia -julia> store = ToolStore(name="agent1") +julia> store = toolStore(name="agent1") julia> tools = loadTools(store, "src/tools") julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) diff --git a/test/toolTest.jl b/test/toolTest.jl index 8057cf1..69e59b3 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -6,12 +6,12 @@ using YiemAgent.type # Path to the real tools directory TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") -@testset "loadTools with ToolStore" begin +@testset "loadTools with toolStore" begin # ------------------------------------------------------------------ # # 1. loadTools throws on non-existent directory # # ------------------------------------------------------------------ # - store = ToolStore(name="test1") + store = toolStore(name="test1") @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") # ------------------------------------------------------------------ # @@ -26,7 +26,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - store2 = ToolStore(name="test2") + store2 = toolStore(name="test2") loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) @test length(loaded) == 3 @@ -102,7 +102,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 7. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # - store3 = ToolStore(name="test3") + store3 = toolStore(name="test3") registry_tools = getTools(store3) @test isempty(registry_tools) @@ -153,8 +153,8 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 9. Per-store isolation — two stores don't share tools # # ------------------------------------------------------------------ # - storeA = ToolStore(name="isolationA") - storeB = ToolStore(name="isolationB") + storeA = toolStore(name="isolationA") + storeB = toolStore(name="isolationB") registerTool(storeA, loaded["getTime"]) registerTool(storeB, loaded["getWeather"]) -- 2.52.0 From 5a27630ccfcde8f446e50e356c3986f27024bf8d Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 12:15:05 +0700 Subject: [PATCH 070/112] update --- src/toolRegistry.jl | 120 +++++++++++++++++++++++++--------------- src/tools/README.md | 21 ++++--- src/tools/getTime.jl | 3 +- src/tools/getWeather.jl | 3 +- test/toolTest.jl | 45 ++++++++++++++- 5 files changed, 132 insertions(+), 60 deletions(-) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 3d786b3..ef33baa 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -22,17 +22,17 @@ struct toolStore end """ -Create a new isolated tool store. + toolStore(; name="default") -> toolStore + +Create a new empty tool store. # Keyword Arguments -- `name::String`: Identifier for this store (default: "default") +- `name::String`: Display name for logging (default: `"default"`) -# Examples +# Example ```julia -store = toolStore(name="agent1") -tools = loadTools(store, "src/tools") -registerTool(store, my_tool) -agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) +julia> store = toolStore(name="agent1") +toolStore(OrderedDict{String, agentTool}(), "agent1") ``` """ function toolStore(; name::String="default")::toolStore @@ -40,14 +40,32 @@ function toolStore(; name::String="default")::toolStore end """ -List tool definition — lets the agent query available tools for collision detection -when creating new tools via writeTool. + listTool(store::toolStore) -> agentTool + +Return an `agentTool` definition for listing registered tools. + +Each call produces a **new** tool object that captures (closes over) +`store`. `loadTools` auto-registers one so the LLM can discover tools +at runtime. # Arguments -- `store::toolStore`: The tool store to list from +- `store`: The tool store whose tools will be listed when the tool runs -Each `toolStore` gets its own `listTool` instance bound to that store, -so each agent sees only its own tools. +# Example +```julia +julia> store = toolStore(name="agent1"); + +julia> loadTools(store, "src/tools") # auto-registers listTools +[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) +[toolRegistry:agent1] Registered tool: listTools + +julia> tools = getTools(store) +OrderedDict{String, agentTool} with 4 entries: + "getWeather" => agentTool(...) + "getTime" => agentTool(...) + "writeTool" => agentTool(...) + "listTools" => agentTool(...) +``` """ function listTool(store::toolStore)::agentTool return agentTool( @@ -80,30 +98,31 @@ function listTool(store::toolStore)::agentTool end """ -Load all tool modules from a directory into a specific toolStore. +Load `.jl` tool files from `dir` into `store`, then auto-register +`listTool` so the LLM can discover available tools at runtime. -Scans `dir` for `.jl` files. Each file must define a function named -`getTool()::agentTool`. Files are sorted alphabetically so tool -registration order is deterministic. - -Each `.jl` file is loaded into its own **submodule** so that all functions -defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, -and any helper functions) are namespaced and never collide with other tools. +Each `.jl` file must define `function getTool()::agentTool ... end`. +Files are sorted alphabetically for deterministic registration order. +Each file is loaded into its own Julia submodule to avoid name collisions. # Arguments -- `store::toolStore`: The tool store to register tools into -- `dir::String`: Directory path to scan for `.jl` tool files +- `store`: Tool store to populate +- `dir`: Directory containing `.jl` tool files # Returns -- `OrderedDict{String, agentTool}`: All loaded tools keyed by name +- The same `store.tools` dict (modified in place) # Errors -- Throws `ArgumentError` if a tool file does not define a `getTool` function +- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()` -# Examples +# Example ```julia -julia> store = toolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") +julia> store = toolStore(name="agent1"); + +julia> loadTools(store, "src/tools") +[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) +[toolRegistry:agent1] Loaded tool: getTime (Time Lookup) +[toolRegistry:agent1] Registered tool: listTools OrderedDict{String, agentTool} with 3 entries: "getWeather" => agentTool(...) "getTime" => agentTool(...) @@ -168,24 +187,30 @@ function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool end end + registerTool(store, listTool(store)) return store.tools end """ -Register a single agentTool into a specific toolStore. + registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} + +Add `tool` to `store`, overwriting any existing tool with the same name. # Arguments -- `store::toolStore`: The tool store to register into -- `tool::agentTool`: The tool to register +- `store`: Tool store to modify +- `tool`: The `agentTool` to register # Returns -- `OrderedDict{String, agentTool}`: Updated tool dict for this store +- The same `store.tools` dict (modified in place) -# Examples +# Example ```julia -julia> store = toolStore(name="agent1") -julia> registerTool(store, my_tool) -[toolRegistry:agent1] Registered tool: my_tool +julia> store = toolStore(name="agent1"); + +julia> registerTool(store, listTool(store)) +[toolRegistry:agent1] Registered tool: listTools +OrderedDict{String, agentTool} with 1 entry: + "listTools" => agentTool(...) ``` """ function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool} @@ -195,22 +220,21 @@ function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, ag end """ -Get the registered tools from a specific toolStore. +Return the tools registered in `store`. -Returns the internal `OrderedDict` directly — O(1) lookup by name, -ordered iteration preserving registration order. +The returned dict is the **same object** stored inside `store` — mutations +to it (e.g. via `registerTool`) are visible through subsequent calls. # Arguments -- `store::toolStore`: The tool store to query +- `store`: Tool store to query # Returns - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order -# Examples +# Example ```julia -julia> getTools(store) -OrderedDict{String, agentTool} with 3 entries: - "listTools" => agentTool(...) +julia> tools = getTools(store) +OrderedDict{String, agentTool} with 2 entries: "getWeather" => agentTool(...) "getTime" => agentTool(...) ``` @@ -220,18 +244,22 @@ function getTools(store::toolStore)::OrderedDict{String, agentTool} end """ -Clear all registered tools from a specific toolStore. +Remove all tools from `store`. # Arguments -- `store::toolStore`: The tool store to clear +- `store`: Tool store to clear # Returns - `nothing` -# Examples +# Example ```julia julia> clearTools(store) [toolRegistry:agent1] Registry cleared +nothing + +julia> getTools(store) +OrderedDict{String, agentTool} with 0 entries ``` """ function clearTools(store::toolStore)::Nothing diff --git a/src/tools/README.md b/src/tools/README.md index f981e3c..3404b54 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -52,7 +52,7 @@ result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) ### Step 2: Load — `loadTools()` -Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. +Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools. ```julia using YiemAgent, YiemAgent.toolRegistry @@ -60,12 +60,13 @@ using YiemAgent, YiemAgent.toolRegistry store = toolStore(name="myAgent") tools = loadTools(store, "src/tools") # Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools +# Also auto-registers listTools for runtime discovery ``` **Result extraction:** ```julia all_tools = getTools(store) # OrderedDict{String, agentTool} -# Keys: "getTime", "getWeather", "writeTool" +# Keys: "getTime", "getWeather", "writeTool", "listTools" getTime_tool = all_tools["getTime"] # Manual registration (alternative to loadTools) @@ -73,7 +74,7 @@ registerTool(store, my_tool) clearTools(store) # Clear all tools from store ``` -**Source:** `toolRegistry.jl:113-172` +**Source:** `toolRegistry.jl:126-178` --- @@ -84,7 +85,7 @@ Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry -# 1. Set up toolStore and load tools +# 1. Set up toolStore and load tools (auto-registers listTools) store = toolStore(name="myAgent") loadTools(store, "src/tools") @@ -105,6 +106,7 @@ agent = yiemAgent( store = toolStore(name="myAgent") registerTool(store, getTime_tool) registerTool(store, getWeather_tool) +registerTool(store, listTool(store)) # needed for manual registration agent = yiemAgent( tools = getTools(store), @@ -297,7 +299,7 @@ end function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} ``` -**Source:** `toolRegistry.jl:113-172` +**Source:** `toolRegistry.jl:126-178` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order @@ -312,7 +314,8 @@ function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool ``` 4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues 5. **Validates** the return value is an `agentTool` instance -6. **Registers** the tool in `store.tools` and returns an `OrderedDict{String, agentTool}` +6. **Registers** the tool in `store.tools` +7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime ### Why Submodules? @@ -328,7 +331,7 @@ Each tool file is loaded into its own **namespaced submodule**. This means: store1 = toolStore(name="agent1") store2 = toolStore(name="agent2") -# Load tools into specific stores +# Load tools into specific stores (auto-registers listTools) tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only @@ -1282,9 +1285,9 @@ The framework supports tools that modify the tool system itself at runtime. ### `listTool` — Discover Available Tools -**Source:** `toolRegistry.jl:54-82` +**Source:** `toolRegistry.jl:55-82` -Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. +Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 94b7752..88098d7 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -43,7 +43,8 @@ Execute the getTime tool. Returns mock time data for the given timezone or city. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult tz = get(args, "timezone", nothing) city = get(args, "city", "") if tz !== nothing diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index e98d6a9..61639cf 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -3,7 +3,8 @@ Execute the getWeather tool. Returns mock weather data for the given city and temperature units. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult city = get(args, "city", "") units = get(args, "units", "celsius") temp = units == "fahrenheit" ? "72" : "22" diff --git a/test/toolTest.jl b/test/toolTest.jl index 69e59b3..b239f36 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -29,21 +29,22 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") store2 = toolStore(name="test2") loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) - @test length(loaded) == 3 + @test length(loaded) == 4 # 3 files + auto-registered listTools names = [k for k in keys(loaded)] @test "getTime" in names @test "getWeather" in names @test "writeTool" in names + @test "listTools" in names # ------------------------------------------------------------------ # # 4. loadTools returns tools sorted alphabetically by filename # - # (getTime.jl < getWeather.jl < writeTool.jl) # - # because 'T' < 'W' in ASCII # + # (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end # # ------------------------------------------------------------------ # @test collect(keys(loaded))[1] == "getTime" @test collect(keys(loaded))[2] == "getWeather" @test collect(keys(loaded))[3] == "writeTool" + @test collect(keys(loaded))[4] == "listTools" # ------------------------------------------------------------------ # # 5. Verify loaded tool fields are correct # @@ -171,3 +172,41 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test isempty(getTools(storeA)) @test !isempty(getTools(storeB)) # storeB unaffected end + +@testset "listTool" begin + store = toolStore(name="test_list") + loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools + + # loadTools auto-registers listTool + @test "listTools" in keys(loaded) + + # listTool returns an agentTool, not a string or array + list_t = listTool(store) + @test list_t isa agentTool + @test list_t.name == "listTools" + @test list_t.label == "List Tools" + @test isempty(list_t.inputSchema["required"]) + + # Verify all tools appear (3 loaded + listTools = 4) + result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x) + @test result isa agentToolResult + @test result.content[1] isa textContent + @test occursin("listTools", result.content[1].text) + @test occursin("getWeather", result.content[1].text) + @test occursin("getTime", result.content[1].text) + @test occursin("writeTool", result.content[1].text) + @test result.details["count"] == 4 + + # Each listTool call creates an independent closure + storeB = toolStore(name="test_listB") + registerTool(storeB, loaded["getWeather"]) + list_tB = listTool(storeB) + + resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) + resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x) + + @test occursin("getWeather", resultA.content[1].text) + @test occursin("getWeather", resultB.content[1].text) + @test occursin("getTime", resultA.content[1].text) + @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather +end -- 2.52.0 From 89885c15838f66752c0dfc1bb30d6db2a5aaa01f Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 16:43:48 +0700 Subject: [PATCH 071/112] update --- src/agentCore.jl | 65 +++++++++---------- src/api.jl | 143 ++++++++++++++++++++++++++++++++++++++++- src/tools/README.md | 18 +++--- src/type.jl | 150 +++----------------------------------------- src/utils.jl | 15 ++++- 5 files changed, 206 insertions(+), 185 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index b854d18..41fd969 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -509,41 +509,42 @@ prepareToolCall(context, msg, tc, config, abortedSignal) ``` """ function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + context::agentContext, + assistantMsg::assistantMessage, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::Union{Nothing, abortSignal}, )::Union{preparedToolCall,immediateOutcome} - tool = get(context.tools, toolCall.name, nothing) - if tool === nothing - return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + tool = get(context.tools, toolCall.name, nothing) + if tool === nothing + return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + end + + try + # 1. prepare arguments (tool-specific transform) + prepared = prepareToolCallArguments(tool, toolCall) + validatedArgs = validateToolArguments(tool, prepared) + + #WORKING 2. beforeToolCall hook — can block + if config.beforeToolCall !== nothing + before = config.beforeToolCall( + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), + signal + ) + if signal !== nothing && signal.aborted + return immediateOutcome(createErrorToolResult("Operation aborted"), true) + end + if before !== nothing && before.block + return immediateOutcome( + createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) + end end - try - # 1. prepare arguments (tool-specific transform) - prepared = prepareToolCallArguments(tool, toolCall) - validatedArgs = validateToolArguments(tool, prepared) - - # 2. beforeToolCall hook — can block - if config.beforeToolCall !== nothing - before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal - ) - if signal !== nothing && signal.aborted - return immediateOutcome(createErrorToolResult("Operation aborted"), true) - end - if before !== nothing && before.block - return immediateOutcome( - createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) - end - end - - return preparedToolCall(tool, toolCall, validatedArgs) - catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) - end + return preparedToolCall(tool, toolCall, validatedArgs) + catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + end end # ── per-call execution ────────────────────────────────────────── @@ -687,7 +688,7 @@ function finalizeExecutedToolCall( if config.afterToolCall !== nothing try after = config.afterToolCall( - afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) if after !== nothing result = merge(result, dict(:content=>get(after,:content,result.content), diff --git a/src/api.jl b/src/api.jl index b126b19..deddf01 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,11 +5,152 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames using GeneralUtils -using ..type, ..utils +using ..type, ..utils, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # +""" +docstring +""" +mutable struct yiemAgent <: agent # High-level agent wrapper + _state::agentState # Current state (prompt, model, messages, tools, etc.) + + # user sends prompt message to agent. if agent is idle, it process user message right away. + # if agent is running, it process user message after the current tool call finished. + inputChannel::Channel + + # Buffers messages the user sends while the agent is busy. Processed after all inputChannel + # messages are handled and the agent is idle (not using a tool call). + followUpChannel::Channel + + # agent sends response message to user after processing all user messages in inputChannel + # and all followUp messages. + outputChannel::Channel + + _agent_loop::Union{Task, Nothing} # agent loop running in the background + + # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, + # reorder, ...) for a single LLM call in _process_message()'s loop. + # returns new Vector{agentMessage} + prepareContext::Union{Function, Nothing} + + # Convert prepareContext()'s new Vector{agentMessage} to LLM message format + formatMsgForLLM::Function + + # Actually invoke the LLM to get a completion response. The LLM response comes back as an + # assistantMessage whose content is an array of content blocks. + # Each block has a type — "text", "thinking", or "toolCall". + # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). + llmCall::Function + + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) + beforeToolCall::Union{Function, Nothing} + + executeToolCalls::Function # execute tool calls () + + # Callback invoked after executing a tool call to sanitize tools output so the output is ready + # to be converted into toolResults message + afterToolCall::Union{Function, Nothing} + # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn + # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context + sessionId::Union{String, Nothing} # Optional session identifier + maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) + parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function + _tool_store::Any # Reference to the toolStore for runtime registration + end + +""" +Create a new yiemAgent instance with a background loop task. + +Spawns a background `@spawn` task that runs the agent loop, listening +on `inputChannel` and `followUpChannel` channels concurrently. + +# Keyword Arguments +- `systemPrompt::String`: System prompt for the agent +- `model`: LLM model to use +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) +- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) +- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) +- `llmCall::Function`: Function to invoke the LLM (required) +- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) +- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) +- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) +- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) +- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) +- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) +- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) +- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) +- `agentEventSink::Function`: Callback to receive agent events +- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) + +# Returns +- A new `yiemAgent` instance with an active background task + +# Examples +```julia +julia> store = toolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) +""" +function yiemAgent( + toolsFolderPath::String, + llmCall::Function, + ; + systemPrompt::String="You are helpful assistant.", + model=nothing, + messages::Vector{agentMessage}=agentMessage[], + prepareContext::Function=prepareContext, + formatMsgForLLM::Function=formatMsgForLLM, + beforeToolCall::Function, + afterToolCall::Function, + # prepareNextTurn::Union{Function, Nothing}=nothing, + # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, + sessionId::Union{String, Nothing}=nothing, + maxRetryDelayMs::Union{Int64, Nothing}=nothing, + parallelToolExecute::Bool=false, + agentEventSink::Function, + tool_store::Union{Any, Nothing}=nothing, + ) + # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) + inputChannel = Channel(16) + followUp = Channel(32) + outputChannel = Channel(16) + + # load tools from toolsFolderPath + toolStore = YiemAgent.toolStore(name="myagent") + loadTools(toolStore, toolsFolderPath) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( + agentState(systemPrompt, model, getTools(toolStore), messages), + inputChannel, + followUp, + outputChannel, + nothing, # placeholder — replaced below + prepareContext, + formatMsgForLLM, + llmCall, + beforeToolCall, + afterToolCall, + # prepareNextTurn, + # prepareNextTurnWithContext, + sessionId, + maxRetryDelayMs, + parallelToolExecute, + agentEventSink, + tool_store, + ) + + # Spawn the background loop and attach it + agent._agent_loop = @spawn _agent_loop(agent) + + return agent +end + + """ Send a message to the agent's input channel. diff --git a/src/tools/README.md b/src/tools/README.md index 3404b54..5062bed 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -633,7 +633,7 @@ function prepareToolCall( - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below 4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` - - Passes `assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context)` and `signal` + - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal` - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` @@ -722,10 +722,10 @@ function finalizeExecutedToolCall( ``` 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - Hook can mutate the result: ```julia - after = config.afterToolCall(afterCtx(...)) + after = config.afterToolCall(afterToolCallContext(...)) if after !== nothing result = merge(result, dict( :content => get(after, :content, result.content), @@ -1146,8 +1146,8 @@ The `agentEventSink` function is a user-provided callback that receives all even | `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | | `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | -| `beforeToolCall` | `(msgCtx::assistantMsgCtx, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | -| `afterToolCall` | `(afterCtx::afterCtx, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | +| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | +| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | ### `beforeToolCall` Hook @@ -1157,7 +1157,7 @@ The `agentEventSink` function is a user-provided callback that receives all even ```julia if config.beforeToolCall !== nothing before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) if signal !== nothing && signal.aborted return immediateOutcome(createErrorToolResult("Operation aborted"), true) @@ -1182,7 +1182,7 @@ end if config.afterToolCall !== nothing try after = config.afterToolCall( - afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) if after !== nothing result = merge(result, dict( @@ -1606,8 +1606,8 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | | `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | -| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | -| `afterCtx` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | +| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | +| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | ### Event Types diff --git a/src/type.jl b/src/type.jl index f01b623..efd9ad1 100644 --- a/src/type.jl +++ b/src/type.jl @@ -13,8 +13,8 @@ # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types - agentLoopConfig, abortSignal, agentToolResult, - assistantMsgCtx, afterCtx, + agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext, + beforeToolCallResult, afterToolCallContext, # Event types toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, # Agent @@ -442,13 +442,18 @@ Context passed to the `beforeToolCall` hook. - `args::Dict{String,Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ -struct assistantMsgCtx +struct beforeToolCallContext message::assistantMessage toolCall::agentToolCall args::Dict{String,Any} context::agentContext end +struct beforeToolCallResult + block::Bool + reason::String +end + """ Context passed to the `afterToolCall` hook. @@ -460,7 +465,7 @@ Context passed to the `afterToolCall` hook. - `isError::Bool`: Whether execution resulted in an error - `context::agentContext`: Current conversation context """ -struct afterCtx +struct afterToolCallContext message::assistantMessage toolCall::agentToolCall args::Dict{String,Any} @@ -521,143 +526,6 @@ end abstract type agent end -""" -docstring -""" -mutable struct yiemAgent <: agent # High-level agent wrapper - _state::agentState # Current state (prompt, model, messages, tools, etc.) - - # user sends prompt message to agent. if agent is idle, it process user message right away. - # if agent is running, it process user message after the current tool call finished. - inputChannel::Channel - - # Buffers messages the user sends while the agent is busy. Processed after all inputChannel - # messages are handled and the agent is idle (not using a tool call). - followUpChannel::Channel - - # agent sends response message to user after processing all user messages in inputChannel - # and all followUp messages. - outputChannel::Channel - - _agent_loop::Union{Task, Nothing} # agent loop running in the background - - # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, - # reorder, ...) for a single LLM call in _process_message()'s loop. - # returns new Vector{agentMessage} - prepareContext ::Union{Function, Nothing} - - # Convert prepareContext()'s new Vector{agentMessage} to LLM message format - formatMsgForLLM::Function - - # Actually invoke the LLM to get a completion response. The LLM response comes back as an - # assistantMessage whose content is an array of content blocks. - # Each block has a type — "text", "thinking", or "toolCall". - # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). - llmCall::Function - - # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) - beforeToolCall::Union{Function, Nothing} - - executeToolCalls::Function # execute tool calls () - - # Callback invoked after executing a tool call to sanitize tools output so the output is ready - # to be converted into toolResults message - afterToolCall::Union{Function, Nothing} - # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn - # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context - sessionId::Union{String, Nothing} # Optional session identifier - maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function - _tool_store::Any # Reference to the toolStore for runtime registration - end - -""" -Create a new yiemAgent instance with a background loop task. - -Spawns a background `@spawn` task that runs the agent loop, listening -on `inputChannel` and `followUpChannel` channels concurrently. - -# Keyword Arguments -- `systemPrompt::String`: System prompt for the agent -- `model`: LLM model to use -- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) -- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) -- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) -- `llmCall::Function`: Function to invoke the LLM (required) -- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) -- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) -- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) -- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) -- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) -- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) -- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) -- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) -- `agentEventSink::Function`: Callback to receive agent events -- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) - -# Returns -- A new `yiemAgent` instance with an active background task - -# Examples -```julia -julia> store = toolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) -""" -function yiemAgent( - ; systemPrompt::String="You are helpful assistant.", - model=nothing, - tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), - messages::Vector{agentMessage}=agentMessage[], - prepareContext::Union{Function, Nothing}=nothing, - formatMsgForLLM::Function=defaultformatMsgForLLM, - llmCall::Function, - beforeToolCall::Union{Function, Nothing}=nothing, - afterToolCall::Union{Function, Nothing}=nothing, - # prepareNextTurn::Union{Function, Nothing}=nothing, - # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, - sessionId::Union{String, Nothing}=nothing, - maxRetryDelayMs::Union{Int64, Nothing}=nothing, - parallelToolExecute::Bool=false, - agentEventSink::Function, - tool_store::Union{Any, Nothing}=nothing, - ) - # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) - inputChannel = Channel(16) - followUp = Channel(32) - outputChannel = Channel(16) - - # Create struct with a placeholder task, then spawn and replace it - agent = yiemAgent( - agentState(systemPrompt, model, tools, messages), - inputChannel, - followUp, - outputChannel, - nothing, # placeholder — replaced below - prepareContext, - formatMsgForLLM, - llmCall, - beforeToolCall, - afterToolCall, - # prepareNextTurn, - # prepareNextTurnWithContext, - sessionId, - maxRetryDelayMs, - parallelToolExecute, - agentEventSink, - tool_store, - ) - - # Spawn the background loop and attach it - agent._agent_loop = @spawn _agent_loop(agent) - - return agent -end - - - """ preparedToolCall(tool, toolCall, args) diff --git a/src/utils.jl b/src/utils.jl index fb75d77..4300cb0 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,7 +1,8 @@ module utils -export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks +export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, + validateToolArguments, _userMessageToOpenAI, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -219,6 +220,16 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + # final context check + + # seek user approval via UI + + # other check + + return beforeToolCallResult(false, "N/A") +end + """ Convert a userMessage to OpenAI message format. """ -- 2.52.0 From 7c14390400d00a8a651df887551456e6cf3735ed Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 17:28:25 +0700 Subject: [PATCH 072/112] update --- src/agentCore.jl | 145 ++++++++++++++++++++++++++++++++++++++++++++++- src/api.jl | 144 +--------------------------------------------- src/utils.jl | 5 +- 3 files changed, 147 insertions(+), 147 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 41fd969..e39e7a6 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export _agent_loop, OpenAiToUserMessage +export yiemAgent, _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads @@ -9,6 +9,147 @@ using ..type, ..utils # ---------------------------------------------- 100 --------------------------------------------- # +""" +docstring +""" +mutable struct yiemAgent <: agent # High-level agent wrapper + _state::agentState # Current state (prompt, model, messages, tools, etc.) + + # user sends prompt message to agent. if agent is idle, it process user message right away. + # if agent is running, it process user message after the current tool call finished. + inputChannel::Channel + + # Buffers messages the user sends while the agent is busy. Processed after all inputChannel + # messages are handled and the agent is idle (not using a tool call). + followUpChannel::Channel + + # agent sends response message to user after processing all user messages in inputChannel + # and all followUp messages. + outputChannel::Channel + + _agent_loop::Union{Task, Nothing} # agent loop running in the background + + # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, + # reorder, ...) for a single LLM call in _process_message()'s loop. + # returns new Vector{agentMessage} + prepareContext::Union{Function, Nothing} + + # Convert prepareContext()'s new Vector{agentMessage} to LLM message format + formatMsgForLLM::Function + + # Actually invoke the LLM to get a completion response. The LLM response comes back as an + # assistantMessage whose content is an array of content blocks. + # Each block has a type — "text", "thinking", or "toolCall". + # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). + llmCall::Function + + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) + beforeToolCall::Union{Function, Nothing} + + executeToolCalls::Function # execute tool calls () + + # Callback invoked after executing a tool call to sanitize tools output so the output is ready + # to be converted into toolResults message + afterToolCall::Union{Function, Nothing} + # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn + # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context + sessionId::Union{String, Nothing} # Optional session identifier + maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) + parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function + _tool_store::Any # Reference to the toolStore for runtime registration + end + +""" +Create a new yiemAgent instance with a background loop task. + +Spawns a background `@spawn` task that runs the agent loop, listening +on `inputChannel` and `followUpChannel` channels concurrently. + +# Keyword Arguments +- `systemPrompt::String`: System prompt for the agent +- `model`: LLM model to use +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) +- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) +- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) +- `llmCall::Function`: Function to invoke the LLM (required) +- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) +- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) +- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) +- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) +- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) +- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) +- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) +- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) +- `agentEventSink::Function`: Callback to receive agent events +- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) + +# Returns +- A new `yiemAgent` instance with an active background task + +# Examples +```julia +julia> store = toolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) +""" +function yiemAgent( + toolsFolderPath::String, + llmCall::Function, + ; + systemPrompt::String="You are helpful assistant.", + model=nothing, + messages::Vector{agentMessage}=agentMessage[], + prepareContext::Function=prepareContext, + formatMsgForLLM::Function=formatMsgForLLM, + beforeToolCall::Function=beforeToolCall, + afterToolCall::Function, + # prepareNextTurn::Union{Function, Nothing}=nothing, + # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, + sessionId::Union{String, Nothing}=nothing, + maxRetryDelayMs::Union{Int64, Nothing}=nothing, + parallelToolExecute::Bool=false, + agentEventSink::Function, + tool_store::Union{Any, Nothing}=nothing, + ) + # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) + inputChannel = Channel(16) + followUp = Channel(32) + outputChannel = Channel(16) + + # load tools from toolsFolderPath + toolStore = YiemAgent.toolStore(name="myagent") + loadTools(toolStore, toolsFolderPath) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( + agentState(systemPrompt, model, getTools(toolStore), messages), + inputChannel, + followUp, + outputChannel, + nothing, # placeholder — replaced below + prepareContext, + formatMsgForLLM, + llmCall, + beforeToolCall, + afterToolCall, + # prepareNextTurn, + # prepareNextTurnWithContext, + sessionId, + maxRetryDelayMs, + parallelToolExecute, + agentEventSink, + tool_store, + ) + + # Spawn the background loop and attach it + agent._agent_loop = @spawn _agent_loop(agent) + + return agent +end + + """ Private agent loop. Runs in a background `@spawn` task. @@ -526,7 +667,7 @@ function prepareToolCall( prepared = prepareToolCallArguments(tool, toolCall) validatedArgs = validateToolArguments(tool, prepared) - #WORKING 2. beforeToolCall hook — can block + # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), diff --git a/src/api.jl b/src/api.jl index deddf01..e7e2dae 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,153 +5,11 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames using GeneralUtils -using ..type, ..utils, ..toolRegistry +using ..type, ..utils, ..agentCore, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # -""" -docstring -""" -mutable struct yiemAgent <: agent # High-level agent wrapper - _state::agentState # Current state (prompt, model, messages, tools, etc.) - - # user sends prompt message to agent. if agent is idle, it process user message right away. - # if agent is running, it process user message after the current tool call finished. - inputChannel::Channel - - # Buffers messages the user sends while the agent is busy. Processed after all inputChannel - # messages are handled and the agent is idle (not using a tool call). - followUpChannel::Channel - - # agent sends response message to user after processing all user messages in inputChannel - # and all followUp messages. - outputChannel::Channel - - _agent_loop::Union{Task, Nothing} # agent loop running in the background - - # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, - # reorder, ...) for a single LLM call in _process_message()'s loop. - # returns new Vector{agentMessage} - prepareContext::Union{Function, Nothing} - - # Convert prepareContext()'s new Vector{agentMessage} to LLM message format - formatMsgForLLM::Function - - # Actually invoke the LLM to get a completion response. The LLM response comes back as an - # assistantMessage whose content is an array of content blocks. - # Each block has a type — "text", "thinking", or "toolCall". - # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). - llmCall::Function - - # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) - beforeToolCall::Union{Function, Nothing} - - executeToolCalls::Function # execute tool calls () - - # Callback invoked after executing a tool call to sanitize tools output so the output is ready - # to be converted into toolResults message - afterToolCall::Union{Function, Nothing} - # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn - # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context - sessionId::Union{String, Nothing} # Optional session identifier - maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function - _tool_store::Any # Reference to the toolStore for runtime registration - end - -""" -Create a new yiemAgent instance with a background loop task. - -Spawns a background `@spawn` task that runs the agent loop, listening -on `inputChannel` and `followUpChannel` channels concurrently. - -# Keyword Arguments -- `systemPrompt::String`: System prompt for the agent -- `model`: LLM model to use -- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) -- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) -- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) -- `llmCall::Function`: Function to invoke the LLM (required) -- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) -- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) -- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) -- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) -- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) -- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) -- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) -- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) -- `agentEventSink::Function`: Callback to receive agent events -- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) - -# Returns -- A new `yiemAgent` instance with an active background task - -# Examples -```julia -julia> store = toolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) -""" -function yiemAgent( - toolsFolderPath::String, - llmCall::Function, - ; - systemPrompt::String="You are helpful assistant.", - model=nothing, - messages::Vector{agentMessage}=agentMessage[], - prepareContext::Function=prepareContext, - formatMsgForLLM::Function=formatMsgForLLM, - beforeToolCall::Function, - afterToolCall::Function, - # prepareNextTurn::Union{Function, Nothing}=nothing, - # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, - sessionId::Union{String, Nothing}=nothing, - maxRetryDelayMs::Union{Int64, Nothing}=nothing, - parallelToolExecute::Bool=false, - agentEventSink::Function, - tool_store::Union{Any, Nothing}=nothing, - ) - # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) - inputChannel = Channel(16) - followUp = Channel(32) - outputChannel = Channel(16) - - # load tools from toolsFolderPath - toolStore = YiemAgent.toolStore(name="myagent") - loadTools(toolStore, toolsFolderPath) - - # Create struct with a placeholder task, then spawn and replace it - agent = yiemAgent( - agentState(systemPrompt, model, getTools(toolStore), messages), - inputChannel, - followUp, - outputChannel, - nothing, # placeholder — replaced below - prepareContext, - formatMsgForLLM, - llmCall, - beforeToolCall, - afterToolCall, - # prepareNextTurn, - # prepareNextTurnWithContext, - sessionId, - maxRetryDelayMs, - parallelToolExecute, - agentEventSink, - tool_store, - ) - - # Spawn the background loop and attach it - agent._agent_loop = @spawn _agent_loop(agent) - - return agent -end - - - """ Send a message to the agent's input channel. diff --git a/src/utils.jl b/src/utils.jl index 4300cb0..8dca2a4 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -2,7 +2,8 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, + beforeToolCall using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -219,7 +220,7 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} return Dict("messages" => messages) end - +#TODO function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult # final context check -- 2.52.0 From ae3e432b024b35233d1d475ddb92a8625736e86e Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 17:35:56 +0700 Subject: [PATCH 073/112] update --- src/agentCore.jl | 2 +- src/utils.jl | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index e39e7a6..ae7cf38 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -104,7 +104,7 @@ function yiemAgent( prepareContext::Function=prepareContext, formatMsgForLLM::Function=formatMsgForLLM, beforeToolCall::Function=beforeToolCall, - afterToolCall::Function, + afterToolCall::Function, #WORKING # prepareNextTurn::Union{Function, Nothing}=nothing, # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, diff --git a/src/utils.jl b/src/utils.jl index 8dca2a4..3191473 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -231,6 +231,18 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::be return beforeToolCallResult(false, "N/A") end +#TODO +function afterToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + # final context check + + # seek user approval via UI + + # other check + + return beforeToolCallResult(false, "N/A") +end + + """ Convert a userMessage to OpenAI message format. """ -- 2.52.0 From 578e8f55bd893dfdc05e4a1a5893bf52590d0f0a Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 18:28:03 +0700 Subject: [PATCH 074/112] update --- src/tools/README.md => README_tools.md | 0 src/agentCore.jl | 55 +++++++++++--------------- src/utils.jl | 16 ++++---- 3 files changed, 30 insertions(+), 41 deletions(-) rename src/tools/README.md => README_tools.md (100%) diff --git a/src/tools/README.md b/README_tools.md similarity index 100% rename from src/tools/README.md rename to README_tools.md diff --git a/src/agentCore.jl b/src/agentCore.jl index ae7cf38..3540a17 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -56,9 +56,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function - _tool_store::Any # Reference to the toolStore for runtime registration - end + agentEventSink::Function # agent emits its status via this function + end """ Create a new yiemAgent instance with a background loop task. @@ -82,17 +81,9 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `agentEventSink::Function`: Callback to receive agent events -- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task - -# Examples -```julia -julia> store = toolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) """ function yiemAgent( toolsFolderPath::String, @@ -104,14 +95,13 @@ function yiemAgent( prepareContext::Function=prepareContext, formatMsgForLLM::Function=formatMsgForLLM, beforeToolCall::Function=beforeToolCall, - afterToolCall::Function, #WORKING + afterToolCall::Function=afterToolCall, #WORKING # prepareNextTurn::Union{Function, Nothing}=nothing, # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, agentEventSink::Function, - tool_store::Union{Any, Nothing}=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -140,7 +130,6 @@ function yiemAgent( maxRetryDelayMs, parallelToolExecute, agentEventSink, - tool_store, ) # Spawn the background loop and attach it @@ -823,28 +812,28 @@ function finalizeExecutedToolCall( signal::Union{Nothing,abortSignal}, )::finalizedOutcome - result = executed.result - isError = executed.isError + result = executed.result + isError = executed.isError - if config.afterToolCall !== nothing - try - after = config.afterToolCall( - afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal - ) - if after !== nothing - result = merge(result, dict(:content=>get(after,:content,result.content), - :details=>get(after,:details,result.details), - :usage=>get(after,:usage,result.usage), - :terminate=>get(after,:terminate,result.terminate))) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true - end + if config.afterToolCall !== nothing + try + after = config.afterToolCall( + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + ) + if after !== nothing + result = merge(result, dict(:content=>get(after,:content,result.content), + :details=>get(after,:details,result.details), + :usage=>get(after,:usage,result.usage), + :terminate=>get(after,:terminate,result.terminate))) + isError = get(after, :isError, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true end + end - return finalizedOutcome(prep.toolCall, result, isError) + return finalizedOutcome(prep.toolCall, result, isError) end """ diff --git a/src/utils.jl b/src/utils.jl index 3191473..4a89c4d 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,7 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, - beforeToolCall + beforeToolCall, afterToolCall using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -221,7 +221,9 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end #TODO -function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal + )::beforeToolCallResult + # final context check # seek user approval via UI @@ -232,14 +234,12 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::be end #TODO -function afterToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult - # final context check +function afterToolCall(context::beforeToolCallContext, signal::abortSignal + )::Union{agentToolResult, Nothing} - # seek user approval via UI - - # other check + # modify context.result if needed and return agentToolResult - return beforeToolCallResult(false, "N/A") + return nothing end -- 2.52.0 From bad14fbe7f121ae976a0bf388f68acab9b09b682 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 18:42:34 +0700 Subject: [PATCH 075/112] update --- src/agentCore.jl | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 3540a17..fbe2dcc 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -46,8 +46,6 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} - executeToolCalls::Function # execute tool calls () - # Callback invoked after executing a tool call to sanitize tools output so the output is ready # to be converted into toolResults message afterToolCall::Union{Function, Nothing} @@ -95,13 +93,13 @@ function yiemAgent( prepareContext::Function=prepareContext, formatMsgForLLM::Function=formatMsgForLLM, beforeToolCall::Function=beforeToolCall, - afterToolCall::Function=afterToolCall, #WORKING + afterToolCall::Function=afterToolCall, # prepareNextTurn::Union{Function, Nothing}=nothing, # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function, + agentEventSink::Function, #WORKING ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) -- 2.52.0 From 83c7770877051fe68c43830da34c3307b6d6c0bf Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 18:57:53 +0700 Subject: [PATCH 076/112] update --- README.md | 4 ++-- README_tools.md | 12 ++++++------ src/agentCore.jl | 2 +- src/api.jl | 26 +++++++++++++------------- src/type.jl | 2 +- src/utils.jl | 8 +++++++- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index aa9f04c..ed1558c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Julia framework for building agents with tool use. 1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` 2. Create a `yiemAgent` with `loadTools("src/tools")` -3. Call `run_agent(agent, "message")` then `take_response(agent)` +3. Call `runAgent(agent, "message")` then `takeResponse(agent)` ## Architecture @@ -16,7 +16,7 @@ src/ ├── type.jl # Core types (messages, tools, agent state) ├── utils.jl # Message formatting, validation ├── agentCore.jl # Agent loop, tool execution pipeline -├── api.jl # Public API (run_agent, take_response, etc.) +├── api.jl # Public API (runAgent, takeResponse, etc.) └── tools/ ├── registry.jl # Tool registry (loadTools, registerTool, listTools) ├── getWeather.jl # Weather lookup tool diff --git a/README_tools.md b/README_tools.md index 5062bed..baf5b2d 100644 --- a/README_tools.md +++ b/README_tools.md @@ -151,7 +151,7 @@ result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) **Via agent loop (production):** ``` -user message → run_agent(agent, Dict("role"=>"user", "content"=>...)) +user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) → _agent_loop detects message → @spawn _process_message(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls @@ -382,9 +382,9 @@ The `_agent_loop()` function runs as a background `@spawn` task, created when `y ``` yiemAgent struct contains: - - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() + - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() + - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() + - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() - _tool_store (toolStore) ← per-agent isolated tool registry ``` @@ -1327,7 +1327,7 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT ``` USER SENDS MESSAGE - └─> run_agent(agent, "What's the weather in Tokyo?") + └─> runAgent(agent, "What's the weather in Tokyo?") └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) @@ -1430,7 +1430,7 @@ LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE AGENT LOOP: SEND RESPONSE TO USER └─> put!(agent.outputChannel, final_response) - └─> take_response(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") + └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") ``` --- diff --git a/src/agentCore.jl b/src/agentCore.jl index fbe2dcc..1e42a48 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -99,7 +99,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function, #WORKING + agentEventSink::Function=agentEventSink, #WORKING ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) diff --git a/src/api.jl b/src/api.jl index e7e2dae..524d09f 100644 --- a/src/api.jl +++ b/src/api.jl @@ -24,16 +24,16 @@ The agent processes messages from `inputChannel` in the background task. - The same `agent` instance for chaining # Notes -- Use `take_response(agent)` to receive the agent's response after sending a message. -- Use `follow_up(agent, msg)` to send messages while the agent is still processing. +- Use `takeResponse(agent)` to receive the agent's response after sending a message. +- Use `followUp(agent, msg)` to send messages while the agent is still processing. # Examples ```jldoctest -julia> run_agent(agent, "Hello!") +julia> runAgent(agent, "Hello!") yiemAgent(...) ``` """ -function run_agent(agent::yiemAgent, msg) +function runAgent(agent::yiemAgent, msg) put!(agent.inputChannel, msg) return agent end @@ -50,15 +50,15 @@ Blocks until the agent sends a response. - An `assistantMessage` instance representing the agent's response # Notes -- Use `run_agent(agent, msg)` to send a message before calling this function. +- Use `runAgent(agent, msg)` to send a message before calling this function. # Examples ```jldoctest -julia> response = take_response(agent) +julia> response = takeResponse(agent) assistantMessage(...) ``` """ -function take_response(agent::yiemAgent) +function takeResponse(agent::yiemAgent) return take!(agent.outputChannel) end @@ -76,17 +76,17 @@ and before any tool call results are sent. - The same `agent` instance for chaining # Notes -- Use `run_agent(agent, msg)` for the primary message and `follow_up(agent, msg)` for additional +- Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional messages while the agent is processing. - Follow-up messages are buffered in a separate channel (capacity 32 by default). # Examples ```jldoctest -julia> follow_up(agent, "Also consider red wines") +julia> followUp(agent, "Also consider red wines") yiemAgent(...) ``` """ -function follow_up(agent::yiemAgent, msg) +function followUp(agent::yiemAgent, msg) put!(agent.followUpChannel, msg) return agent end @@ -104,16 +104,16 @@ then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`). - `nothing` # Notes -- After calling `stop_agent`, the agent is no longer usable. A new agent must be created +- After calling `stopAgent`, the agent is no longer usable. A new agent must be created for further interaction. - If the background task throws a `TaskFailedException`, it is rethrown. # Examples ```jldoctest -julia> stop_agent(agent) +julia> stopAgent(agent) ``` """ -function stop_agent(agent::yiemAgent) +function stopAgent(agent::yiemAgent) put!(agent.inputChannel, :shutdown) try fetch(agent._agent_loop) diff --git a/src/type.jl b/src/type.jl index efd9ad1..11a4aac 100644 --- a/src/type.jl +++ b/src/type.jl @@ -23,7 +23,7 @@ preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, agentToolCallBatch, # Functions (defined elsewhere) - run_agent, take_response, follow_up, stop_agent + runAgent, takeResponse, followUp, stopAgent using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads diff --git a/src/utils.jl b/src/utils.jl index 4a89c4d..8dc73ad 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,7 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, - beforeToolCall, afterToolCall + beforeToolCall, afterToolCall, agentEventSink using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -243,6 +243,12 @@ function afterToolCall(context::beforeToolCallContext, signal::abortSignal end +#TODO +function agentEventSink() + +end + + """ Convert a userMessage to OpenAI message format. """ -- 2.52.0 From 2ad3d1df38fb267ababf1d97db5a034a9362101d Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 19:10:37 +0700 Subject: [PATCH 077/112] update --- src/agentCore.jl | 42 ++++++------------------------------------ src/utils.jl | 2 +- 2 files changed, 7 insertions(+), 37 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 1e42a48..98c197c 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -834,39 +834,6 @@ function finalizeExecutedToolCall( return finalizedOutcome(prep.toolCall, result, isError) end -""" - emitToolExecutionEnd(finalized, emit) - -Emits the `toolExecutionEnd` event with the finalized outcome, -signalling to listeners that the tool call has completed. - -This event is part of the tool execution lifecycle: -`toolExecutionStart` → (zero or more `toolExecutionUpdate` events) → -`toolExecutionEnd`. Listeners (such as the TUI or logging systems) -use this lifecycle to track individual tool calls. The event carries -the final result so listeners have all the data they need without -requiring external state lookups. - -# Arguments -- `finalized::finalizedOutcome`: The finalized outcome to report -- `emit::Function`: Event emitter - -# Notes -- Part of a three-event lifecycle per tool call -- Carries the complete result so listeners need no external lookups - -# Examples -```julia -# Emits a single event; returns nothing -emitToolExecutionEnd(finalized, emit) -# (emit receives toolExecEndEvent("call_1", "search_wine", result, false)) -``` -""" -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) -end - # ── sequential execution ──────────────────────────────────────── """ @@ -945,7 +912,8 @@ function executeToolCallsSequential( finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) end - emitToolExecutionEnd(finalized, emit) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) @@ -1032,13 +1000,15 @@ function executeToolCallsParallel( if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) - emitToolExecutionEnd(finalized, emit) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) push!(entries, finalized) else task = task() do executed = executePreparedToolCall(prep, signal, emit) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emitToolExecutionEnd(finalized, emit) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) return finalized end schedule(task) diff --git a/src/utils.jl b/src/utils.jl index 8dc73ad..37848c8 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -244,7 +244,7 @@ end #TODO -function agentEventSink() +function agentEventSink(x) end -- 2.52.0 From 06d51c1ee956533fa5f18cb637ba551af7d84014 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 04:00:09 +0700 Subject: [PATCH 078/112] update --- src/agentCore.jl | 10 +- src/type.jl | 32 +-- test/runtest.jl | 631 +++++++++++++++++++++-------------------------- 3 files changed, 297 insertions(+), 376 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 98c197c..af7fdb4 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -5,7 +5,7 @@ export yiemAgent, _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads using GeneralUtils -using ..type, ..utils +using ..type, ..utils, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # @@ -85,7 +85,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. """ function yiemAgent( toolsFolderPath::String, - llmCall::Function, + llmCall, ; systemPrompt::String="You are helpful assistant.", model=nothing, @@ -107,12 +107,12 @@ function yiemAgent( outputChannel = Channel(16) # load tools from toolsFolderPath - toolStore = YiemAgent.toolStore(name="myagent") - loadTools(toolStore, toolsFolderPath) + toolStore1 = toolStore(name="myagent") + loadTools(toolStore1, toolsFolderPath) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( - agentState(systemPrompt, model, getTools(toolStore), messages), + agentState(systemPrompt, model, getTools(toolStore1), messages), inputChannel, followUp, outputChannel, diff --git a/src/type.jl b/src/type.jl index 11a4aac..e1836b5 100644 --- a/src/type.jl +++ b/src/type.jl @@ -144,7 +144,7 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en ``` """ function assistantMessage(; role="assistant", content=Vector{messageContent}(), - api="", provider="", model="", usage=llmUsage(0, 0), stopReason="end_turn", + api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn", errorMessage=nothing, timestamp=now()) return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) end @@ -309,7 +309,7 @@ end mutable struct agentState # Mutable runtime state of an agent systemPrompt::String # System prompt for the agent - model::llmModel # LLM model to use + model::Union{llmModel, Nothing} # LLM model to use tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt @@ -341,21 +341,21 @@ julia> state = agentState(systemPrompt="You are a helpful assistant") agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing) """ function agentState( - systemPrompt::String="", - model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], - modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), - tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), - messages::Vector{agentMessage}=agentMessage[], + systemPrompt::String="", + model::llmModel=llmModel{String}("", "unknown", "unknown", "", false, String[], + modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), + messages::Vector{agentMessage}=agentMessage[], ) - agentState( - systemPrompt, - model, - deepcopy(tools), - deepcopy(messages), - Vector{String}(), - false, - nothing, - ) + agentState( + systemPrompt, + model, + deepcopy(tools), + deepcopy(messages), + Vector{String}(), + false, + nothing, + ) end diff --git a/test/runtest.jl b/test/runtest.jl index b03f7c1..54d6850 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -1,232 +1,293 @@ using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, NATS, Base.Threads -using YiemAgent, GeneralUtils, msghandler +using YiemAgent, GeneralUtils - function text2text_instruct_llm(sender_id::String, openai_msg::Dict{String, Any}) - payloads = [("msg", openai_msg, "dictionary")] # List of tuples - _, msg_envelope_json_str = msghandler.smartpack( - config["externalservice"]["servicesloadbalancer"]["nats"], - payloads; - sender_id=sender_id, - msg_purpose="text2text", - broker_url=config["nats_server_info"]["url"], - fileserver_url=config["externalservice"]["fileserver"]["url"]) +struct text2textInstructLLM + natsConn::NATS.Connection + topic::String + senderID::String + fileserver_url::String +end - reply = NATS.request(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - msg_envelope_json_str, timeout=120) +function (t::text2textInstructLLM)(openai_msg::Dict{String, Any}) + payloads = [("msg", openai_msg, "dictionary")] # List of tuples + _, msg_envelope_json_str = msghandler.smartpack( + t.topic, + payloads; + sender_id=t.senderID, + msg_purpose="text2text", + fileserver_url=t.fileserver_url) - incoming_env_json_str = String(reply.payload) - incoming_env = msghandler.smartunpack(incoming_env_json_str) - _llm_response = incoming_env["payloads"][1][2] - llm_response = _llm_response["choices"][1]["message"]["content"] - return llm_response - end + reply = NATS.request(t.natsConn, t.topic, msg_envelope_json_str, timeout=180) - """ get a single text embedding from a LLM service - Example - text = ["hello"] - embedding = get_embedding(text) - """ - function get_embedding(text::AbstractArray{String}) - documents_dict = Dict("documents" => text) - payloads = [("documents", documents_dict, "dictionary")] - _, msg_envelope_json_str = msghandler.smartpack( - config["externalservice"]["servicesloadbalancer"]["nats"], - payloads; - msg_purpose="embedding", - broker_url=config["nats_server_info"]["url"], - fileserver_url=config["externalservice"]["fileserver"]["url"]) + incoming_env_json_str = String(reply.payload) + incoming_env = msghandler.smartunpack(incoming_env_json_str) + _llm_response = incoming_env["payloads"][1][2] + llm_response = _llm_response["choices"][1]["message"]["content"] + return llm_response +end - reply = NATS.request(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - msg_envelope_json_str, timeout=120) - incoming_env_json_str = String(reply.payload) - incoming_env = msghandler.smartunpack(incoming_env_json_str) - embedding_response = incoming_env["payloads"][1][2] - return embedding_response - end +# function get_embedding(text::AbstractArray{String}) +# documents_dict = Dict("documents" => text) +# payloads = [("documents", documents_dict, "dictionary")] +# _, msg_envelope_json_str = msghandler.smartpack( +# config["externalservice"]["servicesloadbalancer"]["nats"], +# payloads; +# msg_purpose="embedding", +# broker_url=config["nats_server_info"]["url"], +# fileserver_url=config["externalservice"]["fileserver"]["url"]) - """ sql = "SELECT * FROM wine;" - result = execute_sql_winedb(sql) - """ - function execute_sql_winedb(sql::T) where {T<:AbstractString} - host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') - port = parse(Int, _port) - dbname = "winedb" - user = config["externalservice"]["sommpanion_db"]["user"] - password = config["externalservice"]["sommpanion_db"]["password"] - db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") - result = nothing - try - result = LibPQ.execute(db_connection, sql) - catch e - LibPQ.close(db_connection) - end +# reply = NATS.request(agent_conn, +# config["externalservice"]["servicesloadbalancer"]["nats"], +# msg_envelope_json_str, timeout=120) +# incoming_env_json_str = String(reply.payload) +# incoming_env = msghandler.smartunpack(incoming_env_json_str) +# embedding_response = incoming_env["payloads"][1][2] + +# return embedding_response +# end + + +# """ sql = "SELECT * FROM wine;" +# result = execute_sql_winedb(sql) +# """ +# function execute_sql_winedb(sql::T) where {T<:AbstractString} +# host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') +# port = parse(Int, _port) +# dbname = "winedb" +# user = config["externalservice"]["sommpanion_db"]["user"] +# password = config["externalservice"]["sommpanion_db"]["password"] +# db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") +# result = nothing +# try +# result = LibPQ.execute(db_connection, sql) +# catch e +# LibPQ.close(db_connection) +# end + +# LibPQ.close(db_connection) +# return result +# end + +# """ find similar sql from vector database +# sql = "SELECT * FROM wine;" +# result, distance = similar_sql_vectordb(sql) +# """ +# function similar_sql_vectordb(sql::T; maxdistance::Number=1) where {T<:AbstractString} +# tablename = "sqlllm_decision_repository" +# # get embedding of the query +# df = find_similar_text_from_vectordb(sql, tablename, +# "function_input_embedding", execute_sql_vectordb) +# # println(df[1, [:id, :function_output]]) +# row, col = size(df) +# distance = row == 0 ? Inf : df[1, :distance] +# if row != 0 && distance < maxdistance +# # if there is usable SQL, return it. +# output_b64 = df[1, :function_output_base64] # pick the closest match +# output_str = String(base64decode(output_b64)) +# rowid = df[1, :id] +# println("\n--| similar sql found. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") +# pprintln(output_str) +# return (result=output_str, distance=distance) +# else +# println("\n--| similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") +# return (result=nothing, distance=nothing) +# end +# end + +# """ insert query and sql into vector database +# query = "get all wines from wine table" +# sql = "SELECT * FROM wine;" +# insert_sql_vectordb(query, sql) +# """ +# function insert_sql_vectordb(query::T1, SQL::T2; maxdistance::Number=3 +# ) where {T1<:AbstractString, T2<:AbstractString} + +# tablename = "sqlllm_decision_repository" +# # get embedding of the query +# # query = state[:thoughtHistory][:question] +# df = find_similar_text_from_vectordb(query, tablename, +# "function_input_embedding", execute_sql_vectordb) +# row, col = size(df) +# distance = row == 0 ? Inf : df[1, :distance] +# if row == 0 || distance > maxdistance # no close enough SQL stored in the database +# _query_embedding = get_embedding([query]) +# _query_embedding = GeneralUtils.dictify(_query_embedding) +# # println("\n--- _query_embedding() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") +# # println(_query_embedding) +# # println("---\n") +# query_embedding = _query_embedding["data"][1]["embedding"] +# query = replace(query, "'" => "") +# sql_base64 = base64encode(SQL) +# sql_ = replace(SQL, "'" => "") + +# sql = +# """ +# INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); +# """ +# # println("\n--| added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") +# # println(sql) +# _ = execute_sql_vectordb(sql) +# end +# end + +# """ execute sql against vectordb +# sql = "SELECT * FROM wine;" +# result = execute_sql_vectordb(sql) +# """ +# function execute_sql_vectordb(sql::T) where {T<:AbstractString} +# host_url, _port = split(config["externalservice"]["sommpanion_vectordb"]["url"], ':') +# port = parse(Int, _port) +# dbname = config["externalservice"]["sommpanion_vectordb"]["dbname"] +# user = config["externalservice"]["sommpanion_vectordb"]["user"] +# password = config["externalservice"]["sommpanion_vectordb"]["password"] +# DBconnection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") +# result = LibPQ.execute(DBconnection, sql) +# close(DBconnection) +# return result +# end + +# """ search similar decision llm made from vectordb +# """ +# function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3 +# )::Union{AbstractDict, Nothing} where {T1<:AbstractString} + +# tablename = "sommelier_decision_repository" +# # find similar +# df = find_similar_text_from_vectordb(recentevents, tablename, +# "function_input_embedding", execute_sql_vectordb) +# row, col = size(df) +# distance = row == 0 ? Inf : df[1, :distance] +# if row != 0 && distance < maxdistance +# # if there is usable decision, return it. +# rowid = df[1, :id] +# println("\n--| found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) +# output_b64 = df[1, :function_output_base64] # pick the closest match +# _output_str = String(base64decode(output_b64)) +# output = copy(JSON.read(_output_str)) +# return output +# else +# println("\n--| similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) +# return nothing +# end +# end + +# """ search similar text from vectordb +# """ +# function find_similar_text_from_vectordb(text::T1, tablename::T2, embeddingColumnName::T3, +# vectorDB::Function; limit::Integer=1 +# )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} +# # get embedding from LLM service +# _embedding = get_embedding([text]) +# _embedding = _embedding["data"][1]["embedding"] +# _embedding = "$_embedding" + +# embedding = _embedding[4:end] # remove 'Any' from Any[...] + +# # check whether there is close enough vector already store in vectorDB. if no, add, else skip +# sql = """ +# SELECT *, $embeddingColumnName <-> '$embedding' as distance +# FROM $tablename +# ORDER BY distance LIMIT $limit; +# """ +# response = vectorDB(sql) +# df = DataFrame(response) + +# return df +# end + +# """ insert decision llm made to vectordb +# """ +# function insert_sommelier_decision(recentevents::T1, decision::T2; maxdistance::Integer=5 +# ) where {T1<:AbstractString, T2<:AbstractDict} +# tablename = "sommelier_decision_repository" +# # find similar +# df = find_similar_text_from_vectordb(recentevents, tablename, +# "function_input_embedding", execute_sql_vectordb) +# row, col = size(df) +# distance = row == 0 ? Inf : df[1, :distance] +# if row == 0 || distance > maxdistance # no close enough SQL stored in the database +# _embedding = get_embedding([recentevents])[1] +# recentevents_embedding = _embedding["data"][1]["embedding"] +# recentevents = replace(recentevents, "'" => "") +# decision_json = JSON.json(decision) +# decision_base64 = base64encode(decision_json) +# decision = replace(decision_json, "'" => "") - LibPQ.close(db_connection) - return result - end +# sql = +# """ +# INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); +# """ +# println("\n--| added new decision to vectorDB ", @__FILE__, " ", @__LINE__) +# println(sql) +# _ = execute_sql_vectordb(sql) +# else +# println("--| similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) +# end +# end - """ find similar sql from vector database - sql = "SELECT * FROM wine;" - result, distance = similar_sql_vectordb(sql) - """ - function similar_sql_vectordb(sql::T; maxdistance::Number=0.2) where {T<:AbstractString} - tablename = "sqlllm_decision_repository" - # get embedding of the query - df = find_similar_text_from_vectordb(sql, tablename, - "function_input_embedding", execute_sql_vectordb) - # println(df[1, [:id, :function_output]]) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable SQL, return it. - output_b64 = df[1, :function_output_base64] # pick the closest match - output_str = String(base64decode(output_b64)) - rowid = df[1, :id] - println("\n--| similar sql found. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(output_str) - return (result=output_str, distance=distance) - else - println("\n--| similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (result=nothing, distance=nothing) - end - end +# function find_related_tables_for_user_question(question::String; top_row_num::Integer=20) - """ insert query and sql into vector database - query = "get all wines from wine table" - sql = "SELECT * FROM wine;" - insert_sql_vectordb(query, sql) - """ - function insert_sql_vectordb(query::T1, SQL::T2; maxdistance::Number=3 - ) where {T1<:AbstractString, T2<:AbstractString} - - tablename = "sqlllm_decision_repository" - # get embedding of the query - # query = state[:thoughtHistory][:question] - df = find_similar_text_from_vectordb(query, tablename, - "function_input_embedding", execute_sql_vectordb) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - _query_embedding = get_embedding([query]) - _query_embedding = GeneralUtils.dictify(_query_embedding) - # println("\n--- _query_embedding() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(_query_embedding) - # println("---\n") - query_embedding = _query_embedding["data"][1]["embedding"] - query = replace(query, "'" => "") - sql_base64 = base64encode(SQL) - sql_ = replace(SQL, "'" => "") +# metadata_df = GeneralUtils.extract_column_metadata(pg_conn_str) +# embedding_ready = GeneralUtils.generate_embedding_payloads(metadata_df) - sql = - """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); - """ - # println("\n--| added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(sql) - _ = execute_sql_vectordb(sql) - end - end +# # use only text content +# embedding_ready_2 = [i["text_content"] for i in embedding_ready] - """ execute sql against vectordb - sql = "SELECT * FROM wine;" - result = execute_sql_vectordb(sql) - """ - function execute_sql_vectordb(sql::T) where {T<:AbstractString} - host_url, _port = split(config["externalservice"]["sommpanion_vectordb"]["url"], ':') - port = parse(Int, _port) - dbname = config["externalservice"]["sommpanion_vectordb"]["dbname"] - user = config["externalservice"]["sommpanion_vectordb"]["user"] - password = config["externalservice"]["sommpanion_vectordb"]["password"] - DBconnection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result - end +# table_embedding = get_embedding(embedding_ready_2) - """ search similar decision llm made from vectordb - """ - function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3 - )::Union{AbstractDict, Nothing} where {T1<:AbstractString} +# _user_question_embedding = get_embedding([question]) +# user_question_embedding = Float64.(_user_question_embedding["data"][1]["embedding"]) +# user_question_similarity = [] - tablename = "sommelier_decision_repository" - # find similar - df = find_similar_text_from_vectordb(recentevents, tablename, - "function_input_embedding", execute_sql_vectordb) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable decision, return it. - rowid = df[1, :id] - println("\n--| found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) - output_b64 = df[1, :function_output_base64] # pick the closest match - _output_str = String(base64decode(output_b64)) - output = copy(JSON.read(_output_str)) - return output - else - println("\n--| similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) - return nothing - end - end +# for i in table_embedding["data"] +# i_data = i["embedding"] +# i_float = Float64.(i_data) +# r = 1 - Distances.cosine_dist(i_float, user_question_embedding) +# push!(user_question_similarity, r) +# end - """ search similar text from vectordb - """ - function find_similar_text_from_vectordb(text::T1, tablename::T2, embeddingColumnName::T3, - vectorDB::Function; limit::Integer=1 - )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} - # get embedding from LLM service - _embedding = get_embedding([text]) - _embedding = _embedding["data"][1]["embedding"] - _embedding = "$_embedding" +# new_df = hcat(metadata_df, DataFrame(user_question_similarity = user_question_similarity)) +# sorted_df = sort(new_df, :user_question_similarity, rev=true) # sort max to min +# _top_20_tables = unique(sorted_df[1:top_row_num, :table_name]) +# top_20_tables = [i for i in _top_20_tables] # convert to Vector{String} +# g, id_to_table, table_to_id = GeneralUtils.harvest_db_undirected_schema_graph(pg_conn_str) +# table_relationship = GeneralUtils.resolve_semantic_cluster(top_20_tables, g, table_to_id, id_to_table) - embedding = _embedding[4:end] +# # tables that I should put schema in LLM context +# return table_relationship +# end - # check whether there is close enough vector already store in vectorDB. if no, add, else skip - sql = """ - SELECT *, $embeddingColumnName <-> '$embedding' as distance - FROM $tablename - ORDER BY distance LIMIT $limit; - """ - response = vectorDB(sql) - df = DataFrame(response) - return df - end +# function prepareContext(state::agentState)::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) + +# return agentCtx +# end + +# function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} + +# end - """ insert decision llm made to vectordb - """ - function insert_sommelier_decision(recentevents::T1, decision::T2; maxdistance::Integer=5 - ) where {T1<:AbstractString, T2<:AbstractDict} - tablename = "sommelier_decision_repository" - # find similar - df = find_similar_text_from_vectordb(recentevents, tablename, - "function_input_embedding", execute_sql_vectordb) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - _embedding = get_embedding([recentevents])[1] - recentevents_embedding = _embedding["data"][1]["embedding"] - recentevents = replace(recentevents, "'" => "") - decision_json = JSON.json(decision) - decision_base64 = base64encode(decision_json) - decision = replace(decision_json, "'" => "") - - sql = - """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); - """ - println("\n--| added new decision to vectorDB ", @__FILE__, " ", @__LINE__) - println(sql) - _ = execute_sql_vectordb(sql) - else - println("--| similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) - end - end config = JSON.parsefile("./appconfig.json") +host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') +port = parse(Int, _port) +dbname = "winedb" +user = config["externalservice"]["sommpanion_db"]["user"] +password = config["externalservice"]["sommpanion_db"]["password"] +pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" sessionId = "0" backend_session_topic = "sommpanion.testsubject" agent_ch = Channel(8) @@ -235,159 +296,19 @@ agent_conn = NATS.connect(config["nats_server_info"]["url"]) sub2 = NATS.subscribe(agent_conn, backend_session_topic) do msg put!(agent_ch, msg) end - -agent_context = YiemAgent.agentcontext( - text2text_instruct_llm, - get_embedding, - execute_sql_winedb, - similar_sql_vectordb, - insert_sql_vectordb, - similar_sommelier_decision, - insert_sommelier_decision - ) - - # can't instantiate - agent = YiemAgent.sommelier( - agent_context; - name="Janie", - id=sessionId, # agent instance id - retailername="Yiem Wine Ltd.", - llmFormatName="" - ) - - -image1_path = "test/large_image.png" -image1_bytes = read(image1_path) -image1_base64_string = base64encode(image1_bytes) -mime_type = "image/png" -data1_uri = "data:$(mime_type);base64,$(image1_base64_string)" - -# 1. Read local file and encode to base64 string -image2_path = "test/small_image.png" -image2_bytes = read(image2_path) -image2_base64_string = base64encode(image2_bytes) -mime_type = "image/png" -data2_uri = "data:$(mime_type);base64,$(image2_base64_string)" - -# 3. Construct payload with the Data URI -message = Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know type of wine in the image?"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - -result = YiemAgent.conversation(agent; userinput=message) -println("\n$result") - - - - - - - - - -# message = Dict( -# "role" => "user", -# "content" => [ -# Dict("type" => "text", "text" => -# " -# เป็นงานเลี้ยงทั่วไป -# "), -# ] -# ) - -# result = YiemAgent.conversation(agent; userinput=message) -# println("\n$result") - - - - - - - - - -# message = Dict( -# "role" => "user", -# "content" => [ -# Dict("type" => "text", "text" => "no thanks. that's all"), -# ] -# ) - -# result = YiemAgent.conversation(agent; userinput=message) -# println("\n$result") - - - - - - -# message = Dict( -# "role" => "user", -# "content" => [ -# Dict("type" => "text", "text" => "What about this wine?"), -# Dict( -# "type" => "image_url", -# "image_url" => Dict("url" => data2_uri) -# ) -# ] -# ) - -# result = YiemAgent.conversation(agent; userinput=message) -# println("\n$result") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +#WORKING load tools +text2text_llm = text2textInstructLLM(agent_conn, + config["externalservice"]["servicesloadbalancer"]["nats"], + "sender", + config["externalservice"]["fileserver"]["url"]) +agent = YiemAgent.yiemAgent( + "/home/ton/docker-apps/sommpanion/agent-backend/tools", + text2text_llm +) -- 2.52.0 From 6c9640996923966de478ad1c4b646b8e11df9fe2 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 04:33:19 +0700 Subject: [PATCH 079/112] update --- src/agentCore.jl | 4 ++-- src/type.jl | 4 ++-- test/runtest.jl | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index af7fdb4..ff4089f 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -54,7 +54,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function + agentEventSink::Function # agent emits its status via this function end """ @@ -99,7 +99,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function=agentEventSink, #WORKING + agentEventSink::Function=agentEventSink, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) diff --git a/src/type.jl b/src/type.jl index e1836b5..0db7744 100644 --- a/src/type.jl +++ b/src/type.jl @@ -342,8 +342,8 @@ agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agen """ function agentState( systemPrompt::String="", - model::llmModel=llmModel{String}("", "unknown", "unknown", "", false, String[], - modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + model=llmModel("model_1", "unknown", "unknown", "", false, String[], + modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), messages::Vector{agentMessage}=agentMessage[], ) diff --git a/test/runtest.jl b/test/runtest.jl index 54d6850..18c4353 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -299,6 +299,11 @@ end +# model=YiemAgent.llmModel("model_1", "unknown", "unknown", "", false, String[], +# YiemAgent.modelCost(0.0, 0.0, 0.0, 0.0), 0, 0) + + + #WORKING load tools text2text_llm = text2textInstructLLM(agent_conn, config["externalservice"]["servicesloadbalancer"]["nats"], -- 2.52.0 From 0cacb5c94abb0d597ae901ddc77445a902ec12c1 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 04:35:34 +0700 Subject: [PATCH 080/112] update --- src/agentCore.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index ff4089f..e913e49 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -37,11 +37,11 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Convert prepareContext()'s new Vector{agentMessage} to LLM message format formatMsgForLLM::Function - # Actually invoke the LLM to get a completion response. The LLM response comes back as an - # assistantMessage whose content is an array of content blocks. + # A callable struct. Actually invoke the LLM to get a completion response. + # The LLM response comes back as an assistantMessage whose content is an array of content blocks. # Each block has a type — "text", "thinking", or "toolCall". # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). - llmCall::Function + llmCall # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} -- 2.52.0 From 4e592173a655467dc1ed04804984c241a7df2ba1 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 14:37:11 +0700 Subject: [PATCH 081/112] update --- etc.jl | 79 +++++++++++- src/agentCore.jl | 26 ++-- test/runtest.jl | 327 ----------------------------------------------- 3 files changed, 95 insertions(+), 337 deletions(-) diff --git a/etc.jl b/etc.jl index 4ce0950..7d92ff2 100644 --- a/etc.jl +++ b/etc.jl @@ -1,2 +1,77 @@ -# ── executeToolCalls() Julia pseudo code ────────────────────────── -# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit +i am not sure that's the case. see my NATS message log: + +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "_process_message 3" +┌ Info: debug +└ payload = "_process_message 5" +┌ Info: debug +└ payload = "_process_message 6" +┌ Info: debug +└ payload = "_process_message 7" + + +my NATS receiver report the following for a long time +┌ Info: debug +└ payload = "new user msg" + +untill I Ctrl + d so shutdown the process then i got the following report +┌ Info: debug +└ payload = "_process_message 3" +┌ Info: debug +└ payload = "_process_message 5" +┌ Info: debug +└ payload = "_process_message 6" +┌ Info: debug +└ payload = "_process_message 7" + + + + + + + + + +my point is if _process_message() actually run then this code in _process_message() +"raw_msg = take!(agent.inputChannel)" +should take the new msg message out of agent.inputChannel and there should be only one debug message showing +┌ Info: debug +└ payload = "new user msg" + +before reaching error("debug marker") + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/agentCore.jl b/src/agentCore.jl index e913e49..1526ce2 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -54,7 +54,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function + agentEventSink # agent emits its status via this function end """ @@ -99,7 +99,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function=agentEventSink, + agentEventSink=agentEventSink, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -209,7 +209,8 @@ function _agent_loop(agent::yiemAgent) if isready(agent.inputChannel) # message will be taken in _process_message() - msg = fetch!(agent.inputChannel) + msg = fetch(agent.inputChannel) + agent.agentEventSink("new user msg") else yield() end @@ -235,9 +236,11 @@ function _agent_loop(agent::yiemAgent) # start _process_message loop if agent._state.activeRun == false + agent.agentEventSink("_agent_loop 2") # Dispatch message through the processing pipeline - processingTask = Threads.@spawn _process_message(agent) + processingTask = Threads.@spawn _process_message(agent) agent._state.activeRun = true + agent.agentEventSink("_agent_loop 3") end # during agent runs, check followUp message after _process_message() is done @@ -301,6 +304,7 @@ julia> # Currently returns a placeholder echo response ``` """ function _process_message(agent::yiemAgent)::assistantMessage + agent.agentEventSink("_process_message 1") # loop until llmCall() response didn't use tool calls final_response = nothing while true @@ -313,21 +317,26 @@ function _process_message(agent::yiemAgent)::assistantMessage Dict( "type" => "image_url", "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") - ) + ), ] - ), + ) """ # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(agent.inputChannel) + agent.agentEventSink("_process_message 2") raw_msg = take!(agent.inputChannel) + agent.agentEventSink("_process_message 3") if raw_msg === :shutdown + agent.agentEventSink("_process_message 4") # Re-emit shutdown signal for the loop to handle put!(agent.inputChannel, :shutdown) break end + agent.agentEventSink("_process_message 5") user_msg = OpenAiToUserMessage(raw_msg) push!(agent._state.messages, user_msg) + agent.agentEventSink("_process_message 6") end # call agent.prepareContext() @@ -336,10 +345,11 @@ function _process_message(agent::yiemAgent)::assistantMessage # Call agent.formatMsgForLLM(agent._state) to format for LLM formatted_messages = agent.formatMsgForLLM(preparedContext) + agent.agentEventSink("_process_message 7") # Call llmCall() (blocking — the task waits here) + error("debug marker") response = agent.llmCall(formatted_messages) - - error(5555555) + agent.agentEventSink("_process_message 8") #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) has_tool_calls = false diff --git a/test/runtest.jl b/test/runtest.jl index 18c4353..e69de29 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -1,327 +0,0 @@ -using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, - NATS, Base.Threads -using YiemAgent, GeneralUtils - -struct text2textInstructLLM - natsConn::NATS.Connection - topic::String - senderID::String - fileserver_url::String -end - -function (t::text2textInstructLLM)(openai_msg::Dict{String, Any}) - payloads = [("msg", openai_msg, "dictionary")] # List of tuples - _, msg_envelope_json_str = msghandler.smartpack( - t.topic, - payloads; - sender_id=t.senderID, - msg_purpose="text2text", - fileserver_url=t.fileserver_url) - - reply = NATS.request(t.natsConn, t.topic, msg_envelope_json_str, timeout=180) - - incoming_env_json_str = String(reply.payload) - incoming_env = msghandler.smartunpack(incoming_env_json_str) - _llm_response = incoming_env["payloads"][1][2] - llm_response = _llm_response["choices"][1]["message"]["content"] - return llm_response -end - - -# function get_embedding(text::AbstractArray{String}) -# documents_dict = Dict("documents" => text) -# payloads = [("documents", documents_dict, "dictionary")] -# _, msg_envelope_json_str = msghandler.smartpack( -# config["externalservice"]["servicesloadbalancer"]["nats"], -# payloads; -# msg_purpose="embedding", -# broker_url=config["nats_server_info"]["url"], -# fileserver_url=config["externalservice"]["fileserver"]["url"]) - -# reply = NATS.request(agent_conn, -# config["externalservice"]["servicesloadbalancer"]["nats"], -# msg_envelope_json_str, timeout=120) -# incoming_env_json_str = String(reply.payload) -# incoming_env = msghandler.smartunpack(incoming_env_json_str) -# embedding_response = incoming_env["payloads"][1][2] - -# return embedding_response -# end - - -# """ sql = "SELECT * FROM wine;" -# result = execute_sql_winedb(sql) -# """ -# function execute_sql_winedb(sql::T) where {T<:AbstractString} -# host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') -# port = parse(Int, _port) -# dbname = "winedb" -# user = config["externalservice"]["sommpanion_db"]["user"] -# password = config["externalservice"]["sommpanion_db"]["password"] -# db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") -# result = nothing -# try -# result = LibPQ.execute(db_connection, sql) -# catch e -# LibPQ.close(db_connection) -# end - -# LibPQ.close(db_connection) -# return result -# end - -# """ find similar sql from vector database -# sql = "SELECT * FROM wine;" -# result, distance = similar_sql_vectordb(sql) -# """ -# function similar_sql_vectordb(sql::T; maxdistance::Number=1) where {T<:AbstractString} -# tablename = "sqlllm_decision_repository" -# # get embedding of the query -# df = find_similar_text_from_vectordb(sql, tablename, -# "function_input_embedding", execute_sql_vectordb) -# # println(df[1, [:id, :function_output]]) -# row, col = size(df) -# distance = row == 0 ? Inf : df[1, :distance] -# if row != 0 && distance < maxdistance -# # if there is usable SQL, return it. -# output_b64 = df[1, :function_output_base64] # pick the closest match -# output_str = String(base64decode(output_b64)) -# rowid = df[1, :id] -# println("\n--| similar sql found. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") -# pprintln(output_str) -# return (result=output_str, distance=distance) -# else -# println("\n--| similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") -# return (result=nothing, distance=nothing) -# end -# end - -# """ insert query and sql into vector database -# query = "get all wines from wine table" -# sql = "SELECT * FROM wine;" -# insert_sql_vectordb(query, sql) -# """ -# function insert_sql_vectordb(query::T1, SQL::T2; maxdistance::Number=3 -# ) where {T1<:AbstractString, T2<:AbstractString} - -# tablename = "sqlllm_decision_repository" -# # get embedding of the query -# # query = state[:thoughtHistory][:question] -# df = find_similar_text_from_vectordb(query, tablename, -# "function_input_embedding", execute_sql_vectordb) -# row, col = size(df) -# distance = row == 0 ? Inf : df[1, :distance] -# if row == 0 || distance > maxdistance # no close enough SQL stored in the database -# _query_embedding = get_embedding([query]) -# _query_embedding = GeneralUtils.dictify(_query_embedding) -# # println("\n--- _query_embedding() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") -# # println(_query_embedding) -# # println("---\n") -# query_embedding = _query_embedding["data"][1]["embedding"] -# query = replace(query, "'" => "") -# sql_base64 = base64encode(SQL) -# sql_ = replace(SQL, "'" => "") - -# sql = -# """ -# INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); -# """ -# # println("\n--| added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") -# # println(sql) -# _ = execute_sql_vectordb(sql) -# end -# end - -# """ execute sql against vectordb -# sql = "SELECT * FROM wine;" -# result = execute_sql_vectordb(sql) -# """ -# function execute_sql_vectordb(sql::T) where {T<:AbstractString} -# host_url, _port = split(config["externalservice"]["sommpanion_vectordb"]["url"], ':') -# port = parse(Int, _port) -# dbname = config["externalservice"]["sommpanion_vectordb"]["dbname"] -# user = config["externalservice"]["sommpanion_vectordb"]["user"] -# password = config["externalservice"]["sommpanion_vectordb"]["password"] -# DBconnection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") -# result = LibPQ.execute(DBconnection, sql) -# close(DBconnection) -# return result -# end - -# """ search similar decision llm made from vectordb -# """ -# function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3 -# )::Union{AbstractDict, Nothing} where {T1<:AbstractString} - -# tablename = "sommelier_decision_repository" -# # find similar -# df = find_similar_text_from_vectordb(recentevents, tablename, -# "function_input_embedding", execute_sql_vectordb) -# row, col = size(df) -# distance = row == 0 ? Inf : df[1, :distance] -# if row != 0 && distance < maxdistance -# # if there is usable decision, return it. -# rowid = df[1, :id] -# println("\n--| found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) -# output_b64 = df[1, :function_output_base64] # pick the closest match -# _output_str = String(base64decode(output_b64)) -# output = copy(JSON.read(_output_str)) -# return output -# else -# println("\n--| similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) -# return nothing -# end -# end - -# """ search similar text from vectordb -# """ -# function find_similar_text_from_vectordb(text::T1, tablename::T2, embeddingColumnName::T3, -# vectorDB::Function; limit::Integer=1 -# )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} -# # get embedding from LLM service -# _embedding = get_embedding([text]) -# _embedding = _embedding["data"][1]["embedding"] -# _embedding = "$_embedding" - -# embedding = _embedding[4:end] # remove 'Any' from Any[...] - -# # check whether there is close enough vector already store in vectorDB. if no, add, else skip -# sql = """ -# SELECT *, $embeddingColumnName <-> '$embedding' as distance -# FROM $tablename -# ORDER BY distance LIMIT $limit; -# """ -# response = vectorDB(sql) -# df = DataFrame(response) - -# return df -# end - -# """ insert decision llm made to vectordb -# """ -# function insert_sommelier_decision(recentevents::T1, decision::T2; maxdistance::Integer=5 -# ) where {T1<:AbstractString, T2<:AbstractDict} -# tablename = "sommelier_decision_repository" -# # find similar -# df = find_similar_text_from_vectordb(recentevents, tablename, -# "function_input_embedding", execute_sql_vectordb) -# row, col = size(df) -# distance = row == 0 ? Inf : df[1, :distance] -# if row == 0 || distance > maxdistance # no close enough SQL stored in the database -# _embedding = get_embedding([recentevents])[1] -# recentevents_embedding = _embedding["data"][1]["embedding"] -# recentevents = replace(recentevents, "'" => "") -# decision_json = JSON.json(decision) -# decision_base64 = base64encode(decision_json) -# decision = replace(decision_json, "'" => "") - -# sql = -# """ -# INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); -# """ -# println("\n--| added new decision to vectorDB ", @__FILE__, " ", @__LINE__) -# println(sql) -# _ = execute_sql_vectordb(sql) -# else -# println("--| similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) -# end -# end - -# function find_related_tables_for_user_question(question::String; top_row_num::Integer=20) - -# metadata_df = GeneralUtils.extract_column_metadata(pg_conn_str) -# embedding_ready = GeneralUtils.generate_embedding_payloads(metadata_df) - -# # use only text content -# embedding_ready_2 = [i["text_content"] for i in embedding_ready] - -# table_embedding = get_embedding(embedding_ready_2) - -# _user_question_embedding = get_embedding([question]) -# user_question_embedding = Float64.(_user_question_embedding["data"][1]["embedding"]) -# user_question_similarity = [] - -# for i in table_embedding["data"] -# i_data = i["embedding"] -# i_float = Float64.(i_data) -# r = 1 - Distances.cosine_dist(i_float, user_question_embedding) -# push!(user_question_similarity, r) -# end - -# new_df = hcat(metadata_df, DataFrame(user_question_similarity = user_question_similarity)) -# sorted_df = sort(new_df, :user_question_similarity, rev=true) # sort max to min -# _top_20_tables = unique(sorted_df[1:top_row_num, :table_name]) -# top_20_tables = [i for i in _top_20_tables] # convert to Vector{String} -# g, id_to_table, table_to_id = GeneralUtils.harvest_db_undirected_schema_graph(pg_conn_str) -# table_relationship = GeneralUtils.resolve_semantic_cluster(top_20_tables, g, table_to_id, id_to_table) - -# # tables that I should put schema in LLM context -# return table_relationship -# end - - -# function prepareContext(state::agentState)::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) - -# return agentCtx -# end - -# function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} - -# end - - -config = JSON.parsefile("./appconfig.json") -host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') -port = parse(Int, _port) -dbname = "winedb" -user = config["externalservice"]["sommpanion_db"]["user"] -password = config["externalservice"]["sommpanion_db"]["password"] -pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" -sessionId = "0" -backend_session_topic = "sommpanion.testsubject" -agent_ch = Channel(8) -agent_conn = NATS.connect(config["nats_server_info"]["url"]) - -sub2 = NATS.subscribe(agent_conn, backend_session_topic) do msg - put!(agent_ch, msg) -end - - - -# model=YiemAgent.llmModel("model_1", "unknown", "unknown", "", false, String[], -# YiemAgent.modelCost(0.0, 0.0, 0.0, 0.0), 0, 0) - - - -#WORKING load tools -text2text_llm = text2textInstructLLM(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - "sender", - config["externalservice"]["fileserver"]["url"]) - -agent = YiemAgent.yiemAgent( - "/home/ton/docker-apps/sommpanion/agent-backend/tools", - text2text_llm -) - - - - - - - - - - - -- 2.52.0 From 77adeb3a6b59b743aad71bcc7c3c75b2dd6f630b Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 20:10:51 +0700 Subject: [PATCH 082/112] update --- README_tools.md | 8 +-- src/agentCore.jl | 147 +++++++++++++++++++++++++++++------------------ src/api.jl | 2 +- src/utils.jl | 22 +++++++ 4 files changed, 119 insertions(+), 60 deletions(-) diff --git a/README_tools.md b/README_tools.md index baf5b2d..de61209 100644 --- a/README_tools.md +++ b/README_tools.md @@ -152,7 +152,7 @@ result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) **Via agent loop (production):** ``` user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) - → _agent_loop detects message → @spawn _process_message(agent) + → _agentLoop detects message → @spawn _process_message(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls → executeToolCalls(context, response, tool_call_list, config, signal, emit) @@ -376,7 +376,7 @@ This ensures that `yiemAgent` instances with different `tool_store` references o **Source:** `agentCore.jl:35-145` -The `_agent_loop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. +The `_agentLoop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. ### Channel Architecture @@ -404,7 +404,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`): ### Loop Logic (simplified) ```julia -function _agent_loop(agent::yiemAgent) +function _agentLoop(agent::yiemAgent) while true # 1. Wait for message from inputChannel (blocking poll) msg = fetch!(agent.inputChannel) # agentCore.jl:84 @@ -1332,7 +1332,7 @@ USER SENDS MESSAGE LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL - └─> _agent_loop: detects msg in inputChannel + └─> _agentLoop: detects msg in inputChannel └─> Threads.@spawn _process_message(agent) ── _process_message ────────────────────────────────────────────── diff --git a/src/agentCore.jl b/src/agentCore.jl index 1526ce2..e438904 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export yiemAgent, _agent_loop, OpenAiToUserMessage +export yiemAgent, _agentLoop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads @@ -27,10 +27,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # and all followUp messages. outputChannel::Channel - _agent_loop::Union{Task, Nothing} # agent loop running in the background + _agentLoop::Union{Task, Nothing} # agent loop running in the background # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, - # reorder, ...) for a single LLM call in _process_message()'s loop. + # reorder, ...) for a single LLM call in _processMessage()'s loop. # returns new Vector{agentMessage} prepareContext::Union{Function, Nothing} @@ -131,7 +131,7 @@ function yiemAgent( ) # Spawn the background loop and attach it - agent._agent_loop = @spawn _agent_loop(agent) + agent._agentLoop = @spawn _agentLoop(agent) return agent end @@ -141,7 +141,7 @@ end Private agent loop. Runs in a background `@spawn` task. Waits on `inputChannel` and `followUpChannel`, processing whichever has a message first. -On each iteration, dispatches the message through `_process_message` and sends the result +On each iteration, dispatches the message through `_processMessage` and sends the result to `outputChannel`. Exits on `:shutdown` signal. # Arguments @@ -160,7 +160,8 @@ to `outputChannel`. Exits on `:shutdown` signal. julia> # Called automatically by yiemAgent constructor ``` """ -function _agent_loop(agent::yiemAgent) +function _agentLoop(agent::yiemAgent) + processMessageInputCh = Channel(32) try processingTask = nothing @@ -208,8 +209,8 @@ function _agent_loop(agent::yiemAgent) while msg === nothing if isready(agent.inputChannel) - # message will be taken in _process_message() - msg = fetch(agent.inputChannel) + # message will be taken in _processMessage() + msg = take!(agent.inputChannel) agent.agentEventSink("new user msg") else yield() @@ -232,29 +233,50 @@ function _agent_loop(agent::yiemAgent) #TODO make sure every running tools ended properly break + else + agent.agentEventSink("_agentLoop push 1") + put!(processMessageInputCh, msg) #WORKING + agent.agentEventSink("_agentLoop push 2") end - # start _process_message loop + # start _processMessage loop if agent._state.activeRun == false - agent.agentEventSink("_agent_loop 2") + agent.agentEventSink("_agentLoop 2") # Dispatch message through the processing pipeline - processingTask = Threads.@spawn _process_message(agent) + processingTask = @spawn _processMessage( + processMessageInputCh, + agent.agentEventSink, + agent._state.messages, + agent._state.systemPrompt, + agent._state.tools, + agent.prepareContext, + agent.formatMsgForLLM, + agent.llmCall, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute, + ) agent._state.activeRun = true - agent.agentEventSink("_agent_loop 3") + agent.agentEventSink("_agentLoop 3") end - - # during agent runs, check followUp message after _process_message() is done + + # during agent runs, check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == false + agent.agentEventSink("_agentLoop 4") # if followUp message available, add them all to agent.inputChannel if isready(agent.followUpChannel) + agent.agentEventSink("_agentLoop 4-1") while isready(agent.followUpChannel) + agent.agentEventSink("_agentLoop 4-2") followMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followMsg) end end + agent.agentEventSink("_agentLoop 4-3") continue # continue to process user message in the next loop elseif typeof(processingTask) == Task && istaskdone(processingTask) == true + agent.agentEventSink("_agentLoop 5") # if agent runs is done but followUpChannel has messages, discard all message in it. # when agent work is done it should not accept follow up msg. # user should put new message in inputChannel instead @@ -268,6 +290,7 @@ function _agent_loop(agent::yiemAgent) agent._state.activeRun = false # reset processingTask = nothing # reset end + agent.agentEventSink("_agentLoop 6") end catch e # On any error, send error response and exit the loop @@ -303,13 +326,25 @@ should be implemented. Currently a placeholder that echoes back the received mes julia> # Currently returns a placeholder echo response ``` """ -function _process_message(agent::yiemAgent)::assistantMessage - agent.agentEventSink("_process_message 1") +function _processMessage( + inputChannel::Channel, + agentEventSink, + messages::Vector{agentMessage}, + systemPrompt::String, + tools::OrderedDict{String, agentTool}, + prepareContext::Function, + formatMessagesForLLM::Function, + llmCall, + beforeToolCall::Union{Function, Nothing}, + afterToolCall::Union{Function, Nothing}, + parallelToolExecute::Bool, +)::assistantMessage + agentEventSink("_processMessage 1") # loop until llmCall() response didn't use tool calls final_response = nothing while true - """ example message in agent.inputChannel + """ example message in inputChannel Dict( "role" => "user", "content" => [ @@ -323,42 +358,44 @@ function _process_message(agent::yiemAgent)::assistantMessage """ # Drain inputChannel and convert OpenAI-format messages to userMessage type - while isready(agent.inputChannel) - agent.agentEventSink("_process_message 2") - raw_msg = take!(agent.inputChannel) - agent.agentEventSink("_process_message 3") + while isready(inputChannel) + agentEventSink("_processMessage 2") + raw_msg = take!(inputChannel) + agentEventSink("_processMessage 3") if raw_msg === :shutdown - agent.agentEventSink("_process_message 4") + agentEventSink("_processMessage 4") # Re-emit shutdown signal for the loop to handle - put!(agent.inputChannel, :shutdown) + put!(inputChannel, :shutdown) break end - agent.agentEventSink("_process_message 5") + agentEventSink("_processMessage 5") user_msg = OpenAiToUserMessage(raw_msg) - push!(agent._state.messages, user_msg) - agent.agentEventSink("_process_message 6") + push!(messages, user_msg) + agentEventSink("_processMessage 6") end + agentEventSink("_processMessage 7") + # call prepareContext() + state = agentState(systemPrompt, nothing, tools, messages) + agentEventSink("_processMessage 8") + preparedContext = prepareContext(state) + agentEventSink("_processMessage 8") + # Call formatMessagesForLLM() to format for LLM + formattedMessages = formatMessagesForLLM(preparedContext) - # call agent.prepareContext() - preparedContext = agent.prepareContext(agent._state) - - # Call agent.formatMsgForLLM(agent._state) to format for LLM - formatted_messages = agent.formatMsgForLLM(preparedContext) - - agent.agentEventSink("_process_message 7") + agentEventSink("_processMessage 10") # Call llmCall() (blocking — the task waits here) + response = llmCall(formattedMessages) + agentEventSink(response) + agentEventSink("_processMessage 11") error("debug marker") - response = agent.llmCall(formatted_messages) - agent.agentEventSink("_process_message 8") - - #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) - has_tool_calls = false - tool_call_list = agentToolCall[] + # Check if LLM used tool calls (inspect content for tool_call blocks) + hasToolCalls = false + toolCallList = agentToolCall[] for content_block in response.content if content_block isa Dict if get(content_block, :type, "") == "tool_calls" - has_tool_calls = true + hasToolCalls = true for tc_data in get(content_block, :tool_calls, []) tc = agentToolCall( type="function", @@ -366,10 +403,10 @@ function _process_message(agent::yiemAgent)::assistantMessage name=get(tc_data, :function, Dict{String,Any}())[:name], arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], ) - push!(tool_call_list, tc) + push!(toolCallList, tc) end elseif get(content_block, :type, "") == "tool_call" - has_tool_calls = true + hasToolCalls = true tc_data = content_block tc = agentToolCall( type="function", @@ -377,35 +414,35 @@ function _process_message(agent::yiemAgent)::assistantMessage name=get(tc_data, :name, ""), arguments=get(tc_data, :arguments, Dict{String,Any}()), ) - push!(tool_call_list, tc) + push!(toolCallList, tc) end end end - if has_tool_calls && length(tool_call_list) > 0 + if hasToolCalls && length(toolCallList) > 0 # Build context and config for executeToolCalls context = agentContext( - agent._state.systemPrompt, - agent._state.messages, - agent._state.tools, + systemPrompt, + messages, + tools, ) config = agentLoopConfig( - agent._state.tools, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute ? "parallel" : "sequential", + tools, + beforeToolCall, + afterToolCall, + parallelToolExecute ? "parallel" : "sequential", ) signal = nothing - emit = agent.agentEventSink + emit = agentEventSink # call executeToolCalls() - batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + batch = executeToolCalls(context, response, toolCallList, config, signal, emit) - # save toolResults to agent._state.messages + # save toolResults to messages for tool_result in batch.messages - push!(agent._state.messages, tool_result) + push!(messages, tool_result) end if batch.terminate diff --git a/src/api.jl b/src/api.jl index 524d09f..50d6dbd 100644 --- a/src/api.jl +++ b/src/api.jl @@ -116,7 +116,7 @@ julia> stopAgent(agent) function stopAgent(agent::yiemAgent) put!(agent.inputChannel, :shutdown) try - fetch(agent._agent_loop) + fetch(agent._agentLoop) catch e if e isa TaskFailedException rethrow(e) diff --git a/src/utils.jl b/src/utils.jl index 37848c8..67c4a1b 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -192,6 +192,22 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} ] ), ], + "tools"=> [ + Dict( + "type" => "function", + "function" => Dict( + "name" => "get_weather", + "description" => "Get current weather", + "parameters" => Dict( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string") + ), + "required" => ["city"] + ) + ) + ) + ], "temperature" => 0.7 ) """ @@ -217,6 +233,12 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end end + #WORKING convert ctx.tools into openai's tools format. + # ctx.tools has the following format + # tools = OrderedDict{String, YiemAgent.type.agentTool}("getTime" => YiemAgent.type.agentTool("getTime", "Time Lookup", "Get current local time for a timezone or city.", Dict{String, Any}("properties" => Dict("city" => Dict("type" => "string", "description" => "City name as fallback"), "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'")), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry._tool_getTime.executeTool, nothing, YiemAgent.toolRegistry._tool_getTime.validateRequiredArgs, false), "getWeather" => YiemAgent.type.agentTool("getWeather", "Weather Lookup", "Fetch current weather and forecast for a given city.", Dict{String, Any}("properties" => Dict{String, Dict{String}}("units" => Dict{String, Any}("default" => "celsius", "type" => "string", "description" => "Temperature scale", "enum" => ["celsius", "fahrenheit"]), "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'")), "required" => ["city"], "type" => "object"), YiemAgent.toolRegistry._tool_getWeather.executeTool, nothing, nothing, false), "listTools" => YiemAgent.type.agentTool("listTools", "List Tools", "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", Dict{String, Any}("properties" => Dict{String, Any}(), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry.var"#listTool##0#listTool##1"{YiemAgent.toolRegistry.toolStore}(YiemAgent.toolRegistry.toolStore(OrderedDict{String, YiemAgent.type.agentTool}(#= circular reference @-4 =#), "myagent")), nothing, nothing, false)) + + + return Dict("messages" => messages) end -- 2.52.0 From 90fb97a4e7227c6e0cc232eff2ba39c09a1de978 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 23:47:10 +0700 Subject: [PATCH 083/112] update --- src/agentCore.jl | 45 ++++++++++++++++------------- src/type.jl | 4 +-- src/utils.jl | 73 +++++++++++++++++++++++++++++++++++------------- 3 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index e438904..3d3d357 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -3,7 +3,7 @@ module agentCore export yiemAgent, _agentLoop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Base.Threads + DataFrames, Base.Threads, NATS using GeneralUtils using ..type, ..utils, ..toolRegistry @@ -342,20 +342,23 @@ function _processMessage( agentEventSink("_processMessage 1") # loop until llmCall() response didn't use tool calls final_response = nothing + + """ example message in inputChannel + 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:mime_type;base64,image2_base64_string") + ), + ] + ) + """ + while true - """ example message in inputChannel - 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:mime_type;base64,image2_base64_string") - ), - ] - ) - """ + # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) @@ -377,22 +380,26 @@ function _processMessage( # call prepareContext() state = agentState(systemPrompt, nothing, tools, messages) agentEventSink("_processMessage 8") - preparedContext = prepareContext(state) + preparedContext = prepareContext(state, agentEventSink) agentEventSink("_processMessage 8") # Call formatMessagesForLLM() to format for LLM - formattedMessages = formatMessagesForLLM(preparedContext) + formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) agentEventSink("_processMessage 10") # Call llmCall() (blocking — the task waits here) response = llmCall(formattedMessages) - agentEventSink(response) + + """ response example + response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) + """ + + agentEventSink(string(response)) agentEventSink("_processMessage 11") - error("debug marker") - # Check if LLM used tool calls (inspect content for tool_call blocks) + #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) hasToolCalls = false toolCallList = agentToolCall[] - for content_block in response.content + for content_block in response.content # extract response if content_block isa Dict if get(content_block, :type, "") == "tool_calls" hasToolCalls = true diff --git a/src/type.jl b/src/type.jl index 0db7744..9b8d9bd 100644 --- a/src/type.jl +++ b/src/type.jl @@ -291,7 +291,7 @@ Snapshot of the agent's conversation context. # Arguments - `systemPrompt::String`: System prompt for the agent - `messages::Vector{agentMessage}`: Conversation messages -- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup +- `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup # Returns - A new `agentContext` instance @@ -299,7 +299,7 @@ Snapshot of the agent's conversation context. struct agentContext # Snapshot of the agent's conversation context systemPrompt::String # System prompt for the agent messages::Vector{agentMessage} # Conversation messages - tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name + tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name end diff --git a/src/utils.jl b/src/utils.jl index 67c4a1b..18e5147 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -2,10 +2,10 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, beforeToolCall, afterToolCall, agentEventSink -using UUIDs, Dates, DataStructures, HTTP, JSON +using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils using ..type @@ -75,7 +75,6 @@ function availableWineToText(vecd::Vector)::String end - """ prepareContext(state::agentState) -> agentContext @@ -110,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages) # end ``` """ -function prepareContext(state::agentState)::agentContext +function prepareContext(state::agentState, agentEventSink)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools @@ -157,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [ ]) ``` """ -function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} """ openai message format example msg = Dict( @@ -185,18 +184,12 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} Dict("type" => "text", "text" => "let me check."), ] ), - Dict( - "role" => "toolResult", - "content" => [ - Dict("type" => "text", "text" => "name: Chateau Montelena ..."), - ] - ), ], "tools"=> [ Dict( "type" => "function", "function" => Dict( - "name" => "get_weather", + "name" => "getWeather", "description" => "Get current weather", "parameters" => Dict( "type" => "object", @@ -212,8 +205,10 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} ) """ + 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( @@ -221,7 +216,7 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] )) end - + agentEventSink("formatMsgForLLM 2") # Conversation messages for msg in ctx.messages if msg isa userMessage @@ -232,14 +227,18 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} 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 - #WORKING convert ctx.tools into openai's tools format. - # ctx.tools has the following format - # tools = OrderedDict{String, YiemAgent.type.agentTool}("getTime" => YiemAgent.type.agentTool("getTime", "Time Lookup", "Get current local time for a timezone or city.", Dict{String, Any}("properties" => Dict("city" => Dict("type" => "string", "description" => "City name as fallback"), "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'")), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry._tool_getTime.executeTool, nothing, YiemAgent.toolRegistry._tool_getTime.validateRequiredArgs, false), "getWeather" => YiemAgent.type.agentTool("getWeather", "Weather Lookup", "Fetch current weather and forecast for a given city.", Dict{String, Any}("properties" => Dict{String, Dict{String}}("units" => Dict{String, Any}("default" => "celsius", "type" => "string", "description" => "Temperature scale", "enum" => ["celsius", "fahrenheit"]), "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'")), "required" => ["city"], "type" => "object"), YiemAgent.toolRegistry._tool_getWeather.executeTool, nothing, nothing, false), "listTools" => YiemAgent.type.agentTool("listTools", "List Tools", "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", Dict{String, Any}("properties" => Dict{String, Any}(), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry.var"#listTool##0#listTool##1"{YiemAgent.toolRegistry.toolStore}(YiemAgent.toolRegistry.toolStore(OrderedDict{String, YiemAgent.type.agentTool}(#= circular reference @-4 =#), "myagent")), nothing, nothing, false)) - + if !isempty(tools_array) + openaiReadyMsg["tools"] = tools_array + end - - return Dict("messages" => messages) + return openaiReadyMsg end #TODO @@ -331,6 +330,40 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{ 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} -- 2.52.0 From 510cf6126c87f773c7ca078549aa6137187fd2fd Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 13 Aug 2026 05:56:08 +0700 Subject: [PATCH 084/112] update --- src/agentCore.jl | 144 +++++++++++----- test/_extractToolCalls.jl | 343 ++++++++++++++++++++++++++++++++++++++ test/runtest.jl | 5 + 3 files changed, 452 insertions(+), 40 deletions(-) create mode 100644 test/_extractToolCalls.jl diff --git a/src/agentCore.jl b/src/agentCore.jl index 3d3d357..9f28e77 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export yiemAgent, _agentLoop, OpenAiToUserMessage +export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads, NATS @@ -235,7 +235,7 @@ function _agentLoop(agent::yiemAgent) break else agent.agentEventSink("_agentLoop push 1") - put!(processMessageInputCh, msg) #WORKING + put!(processMessageInputCh, msg) agent.agentEventSink("_agentLoop push 2") end @@ -357,9 +357,6 @@ function _processMessage( """ while true - - - # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) agentEventSink("_processMessage 2") @@ -385,47 +382,19 @@ function _processMessage( # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) - agentEventSink("_processMessage 10") - # Call llmCall() (blocking — the task waits here) - response = llmCall(formattedMessages) + agentEventSink("_processMessage 10") """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ - + response = llmCall(formattedMessages) agentEventSink(string(response)) + agentEventSink("_processMessage 11") - #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) - hasToolCalls = false - toolCallList = agentToolCall[] - - for content_block in response.content # extract response - if content_block isa Dict - if get(content_block, :type, "") == "tool_calls" - hasToolCalls = true - for tc_data in get(content_block, :tool_calls, []) - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :function, Dict{String,Any}())[:name], - arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], - ) - push!(toolCallList, tc) - end - elseif get(content_block, :type, "") == "tool_call" - hasToolCalls = true - tc_data = content_block - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :name, ""), - arguments=get(tc_data, :arguments, Dict{String,Any}()), - ) - push!(toolCallList, tc) - end - end - end - + # Extract tool calls from LLM response content blocks + hasToolCalls, toolCallList = _extractToolCalls(response) + agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") + error("debug marker") if hasToolCalls && length(toolCallList) > 0 # Build context and config for executeToolCalls context = agentContext( @@ -560,6 +529,101 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage ) end +""" + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}} + +Extracts tool calls from the LLM response. Supports two response formats: + +1. **Message format** (e.g. from LMStudio.jl / vLLM): + `response["message"]["tool_calls"]` — array of tool call objects with + `"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`, + and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`. + +2. **Content blocks format** (e.g. from OpenAI API): + `response.content` — array of content blocks. Blocks with `"type" => "tool_calls"` + contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` + have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). + +Returns `(hasToolCalls, toolCallList)` where `hasToolCalls` is `true` if any +tool calls were found, and `toolCallList` is a vector of `agentToolCall` structs. + +# Arguments +- `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) + +# Returns +- `Tuple{Bool, Vector{agentToolCall}}`: `(hasToolCalls, toolCallList)` +""" +function _extractToolCalls(response) + hasToolCalls = false + toolCallList = agentToolCall[] + + # Helper: parse args (JSON string -> Dict, or pass through) + parse_args(raw) = raw isa AbstractDict ? Dict{String,Any}(raw) : + raw isa String ? JSON.parse(raw) : Dict{String,Any}() + + # Helper: build agentToolCall (positional) + make_tc(tc_data, default_id=string(uuid4())) = begin + func = get(tc_data, "function", Dict{String,Any}()) + args = parse_args(get(func, "arguments", "{}")) + name = get(func, "name", "") + id_val = get(tc_data, "id", default_id) + agentToolCall("function", id_val, name, args) + end + + # Format 1: response["message"]["tool_calls"] (LMStudio.jl / vLLM style) + msg = get(response, "message", nothing) + if msg !== nothing && msg isa AbstractDict + tc_array = get(msg, "tool_calls", nothing) + if tc_array !== nothing && tc_array isa Vector + for tc_data in tc_array + if tc_data isa AbstractDict + hasToolCalls = true + push!(toolCallList, make_tc(tc_data)) + end + end + end + end + + # Format 2: response.content blocks (OpenAI API style) + if !hasToolCalls + content = nothing + if response isa AbstractDict + content = get(response, "content", nothing) + else + try + content = getfield(response, :content) + catch + content = nothing + end + end + if content isa Vector + for content_block in content + if content_block isa AbstractDict + if get(content_block, "type", "") == "tool_calls" + hasToolCalls = true + for tc_data in get(content_block, "tool_calls", []) + if tc_data isa AbstractDict + push!(toolCallList, make_tc(tc_data)) + end + end + elseif get(content_block, "type", "") == "tool_call" + hasToolCalls = true + tc = agentToolCall( + "function", + get(content_block, "id", string(uuid4())), + get(content_block, "name", ""), + get(content_block, "arguments", Dict{String,Any}()), + ) + push!(toolCallList, tc) + end + end + end + end + end + + return hasToolCalls, toolCallList +end + """ shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl new file mode 100644 index 0000000..3d97fee --- /dev/null +++ b/test/_extractToolCalls.jl @@ -0,0 +1,343 @@ +using Test +using YiemAgent +using YiemAgent.agentCore +using YiemAgent.type +using JSON + +# Import the function from the private module scope +import YiemAgent.agentCore: _extractToolCalls + +@testset "_extractToolCalls" begin + + # -------------------------------------------------------------- # + # Format 1: response["message"]["tool_calls"] (LMStudio.jl style) # + # -------------------------------------------------------------- # + + @testset "single tool call via message format" begin + response = Dict{String,Any}( + "finish_reason" => "tool_calls", + "index" => 0, + "message" => Dict{String,Any}( + "role" => "assistant", + "content" => "", + "reasoning_content" => "Let me check the weather.", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Bangkok, Thailand\"}", + ), + "id" => "tc_001", + ) + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].id == "tc_001" + @test tc_list[1].arguments["city"] == "Bangkok, Thailand" + end + + @testset "multiple tool calls via message format" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "content" => "", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Tokyo, Japan\"}", + ), + "id" => "tc_001", + ), + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{\"timezone\":\"Asia/Tokyo\"}", + ), + "id" => "tc_002", + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 2 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Tokyo, Japan" + @test tc_list[2].name == "getTime" + @test tc_list[2].arguments["timezone"] == "Asia/Tokyo" + end + + @testset "tool call with empty arguments string" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "listTools", + "arguments" => "{}", + ), + "id" => "tc_empty", + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "listTools" + @test tc_list[1].arguments == Dict{String,Any}() + end + + @testset "tool call with missing id falls back to uuid" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{\"city\":\"NYC\"}", + ), + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test !isempty(tc_list[1].id) + @test tc_list[1].name == "getTime" + end + + @testset "tool call with non-string arguments (pre-parsed)" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => Dict{String,Any}("city" => "London", "units" => "fahrenheit"), + ), + "id" => "tc_parsed", + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].arguments["city"] == "London" + @test tc_list[1].arguments["units"] == "fahrenheit" + end + + # ----------------------------------------------------------- # + # Format 2: response.content blocks (OpenAI API style) # + # ----------------------------------------------------------- # + + @testset "content blocks with tool_calls" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Let me check."), + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Paris\"}", + ), + "id" => "tc_block_1", + ), + ], + ), + ], + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Paris" + end + + @testset "content blocks with tool_call (single-call format)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_single", + "name" => "getTime", + "arguments" => Dict{String,Any}("timezone" => "Europe/London"), + ), + ], + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getTime" + @test tc_list[1].id == "tc_single" + @test tc_list[1].arguments["timezone"] == "Europe/London" + end + + # ----------------------------------------------------------- # + # Format 2 via struct-like object (no .content field) # + # ----------------------------------------------------------- # + + @testset "no tool calls found" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Hello world."), + ], + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "empty message" begin + response = Dict{String,Any}() + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "message with empty tool_calls array" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "Format 1 takes priority over Format 2" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Format1\"}", + ), + "id" => "tc_fmt1", + ), + ], + ), + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_fmt2", + "name" => "getTime", + "arguments" => Dict{String,Any}("city" => "Format2"), + ), + ], + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + end + + # ----------------------------------------------------------- # + # edge cases # + # ----------------------------------------------------------- # + + @testset "tool call with null arguments" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => nothing, + ), + "id" => "tc_null", + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getTime" + end + + @testset "tool call with missing function key" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "id" => "tc_nofunc", + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "" + end + + @testset "tool call with missing name in function block" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}("arguments" => "{}"), + "id" => "tc_noname", + ), + ], + ), + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "" + end + + @testset "message format with JSON.Object (JSON.parse result)" begin + json_str = JSON.json(Dict( + "message" => Dict( + "role" => "assistant", + "tool_calls" => [ + Dict( + "type" => "function", + "function" => Dict("name" => "getWeather", "arguments" => "{\"city\":\"Test\"}"), + "id" => "tc_jsonobj", + ), + ], + ), + )) + parsed = JSON.parse(json_str) + has_toolcalls, tc_list = _extractToolCalls(parsed) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Test" + end + +end diff --git a/test/runtest.jl b/test/runtest.jl index e69de29..6f086e5 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -0,0 +1,5 @@ +using Test +using YiemAgent + +include("toolTest.jl") +include("_extractToolCalls.jl") -- 2.52.0 From b8067c2d3379a0d0c9c5a85adf858b8cf8cb4163 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 13 Aug 2026 18:14:46 +0700 Subject: [PATCH 085/112] update --- src/agentCore.jl | 400 ++++++++++++++++++++++++-------------- src/type.jl | 5 +- test/_extractToolCalls.jl | 351 ++++++++++++++++++++++++++++++--- 3 files changed, 582 insertions(+), 174 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 9f28e77..d6e408c 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -392,30 +392,29 @@ function _processMessage( agentEventSink("_processMessage 11") # Extract tool calls from LLM response content blocks - hasToolCalls, toolCallList = _extractToolCalls(response) + hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") - error("debug marker") + if hasToolCalls && length(toolCallList) > 0 - # Build context and config for executeToolCalls - context = agentContext( - systemPrompt, - messages, - tools, - ) + #WORKING Build context and config for executeToolCalls config = agentLoopConfig( - tools, beforeToolCall, afterToolCall, parallelToolExecute ? "parallel" : "sequential", ) - signal = nothing - emit = agentEventSink + signal = abortSignal(false) + agentEventSink("_processMessage 12") # call executeToolCalls() - batch = executeToolCalls(context, response, toolCallList, config, signal, emit) + batch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, + agentEventSink) + + + agentEventSink("_processMessage 13") + error("debug marker") # save toolResults to messages for tool_result in batch.messages push!(messages, tool_result) @@ -438,9 +437,9 @@ function _processMessage( final_response = assistantMessage( role="assistant", content=final_content, - api=response.api, - model=response.model, - usage=response.usage, + api=assistant_msg.api, + model=assistant_msg.model, + usage=assistant_msg.usage, stopReason="tool_use_terminated", errorMessage=if any(x -> x.isError, batch.messages) "One or more tool calls failed" @@ -453,7 +452,7 @@ function _processMessage( end else # LLM did not use tool calls — this is the final response - final_response = response + final_response = assistant_msg break end end @@ -488,7 +487,7 @@ agentToolResult([textContent("text", "Tool not found")], Dict{Any,Any}()) ``` """ function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], dict{any,any}()) + return agentToolResult([textContent("text", msg)], dict{any,any}()) end """ @@ -530,9 +529,10 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage end """ - _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}} + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, assistantMessage} -Extracts tool calls from the LLM response. Supports two response formats: +Extracts tool calls from the LLM response and constructs an `assistantMessage`. +Supports two response formats: 1. **Message format** (e.g. from LMStudio.jl / vLLM): `response["message"]["tool_calls"]` — array of tool call objects with @@ -544,14 +544,19 @@ Extracts tool calls from the LLM response. Supports two response formats: contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). -Returns `(hasToolCalls, toolCallList)` where `hasToolCalls` is `true` if any -tool calls were found, and `toolCallList` is a vector of `agentToolCall` structs. +The `assistantMessage` is constructed from: +- `reasoning_content` (string) → added as a `textContent` block +- `response.content` blocks (text/ reasoning) → added to content +- Top-level `api`, `provider`, `model`, `usage` → copied to the message +- `finish_reason` → used as `stopReason` # Arguments - `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) # Returns -- `Tuple{Bool, Vector{agentToolCall}}`: `(hasToolCalls, toolCallList)` +- `Tuple{Bool, Vector{agentToolCall}, assistantMessage}`: `(hasToolCalls, toolCallList, assistantMsg)` + +# Example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ function _extractToolCalls(response) hasToolCalls = false @@ -600,9 +605,9 @@ function _extractToolCalls(response) for content_block in content if content_block isa AbstractDict if get(content_block, "type", "") == "tool_calls" - hasToolCalls = true for tc_data in get(content_block, "tool_calls", []) if tc_data isa AbstractDict + hasToolCalls = true push!(toolCallList, make_tc(tc_data)) end end @@ -621,7 +626,88 @@ function _extractToolCalls(response) end end - return hasToolCalls, toolCallList + # ── Construct assistantMessage from response ────────────────────── + # Check Format 1 nested message for reasoning_content, role, etc. + reasoning = get(response, "reasoning_content", nothing) + if reasoning === nothing && msg !== nothing && msg isa AbstractDict + reasoning = get(msg, "reasoning_content", nothing) + end + if reasoning isa String + reasoning_text = reasoning + elseif reasoning isa textContent + reasoning_text = reasoning.text + else + reasoning_text = "" + end + reasoning_block = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] + + finish_reason = get(response, "finish_reason", nothing) + stop_reason = finish_reason isa AbstractString ? String(finish_reason) : "end_turn" + + api = get(response, "api", "") + provider = get(response, "provider", "") + model = get(response, "model", nothing) + usage = get(response, "usage", nothing) + + content_from_response = get(response, "content", nothing) + content_blocks = Vector{messageContent}() + if content_from_response isa Vector + for block in content_from_response + if block isa AbstractDict + if get(block, "type", "") == "text" + push!(content_blocks, textContent(get(block, "text", ""))) + elseif get(block, "type", "") == "tool_call" + # tool_call blocks — don't add text content for these + elseif get(block, "type", "") == "tool_calls" + # tool_calls blocks — don't add text content for these + elseif get(block, "type", "") == "reasoning" + push!(content_blocks, textContent(get(block, "text", ""))) + else + push!(content_blocks, textContent(get(block, "text", ""))) + end + end + end + end + + # Combine content blocks and reasoning + if !isempty(content_blocks) && !isempty(reasoning_block) + all_content = vcat(reasoning_block, content_blocks) + elseif !isempty(content_blocks) + all_content = content_blocks + elseif !isempty(reasoning_block) + all_content = reasoning_block + else + all_content = textContent[] + end + + error_msg = get(response, "error_message", get(response, "errorMessage", nothing)) + + if usage === nothing || !(usage isa llmUsage) + usage = llmUsage(0, 0) + end + + # Role: prefer Format 1 nested message, fallback to top-level, default "assistant" + role = get(response, "role", "assistant") + if role == "assistant" && msg !== nothing && msg isa AbstractDict + nested_role = get(msg, "role", nothing) + if nested_role isa AbstractString + role = nested_role + end + end + + assistant_msg = assistantMessage( + role = role, + content = all_content, + api = api isa AbstractString ? String(api) : "", + provider = provider isa AbstractString ? String(provider) : "", + model = model, + usage = usage, + stopReason = stop_reason, + errorMessage = error_msg, + timestamp = now(), + ) + + return hasToolCalls, toolCallList, assistant_msg end """ @@ -689,14 +775,14 @@ prepareToolCallArguments(normalizeTool, tc) ``` """ function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall - if tool.prepareArguments === nothing - return toolCall - end - prepared = tool.prepareArguments(toolCall.arguments) - if prepared == toolCall.arguments - return toolCall - end - return merge(toolCall, dict(:arguments => prepared)) + if tool.prepareArguments === nothing + return toolCall + end + prepared = tool.prepareArguments(toolCall.arguments) + if prepared == toolCall.arguments + return toolCall + end + return merge(toolCall, dict(:arguments => prepared)) end """ @@ -759,37 +845,55 @@ function prepareToolCall( assistantMsg::assistantMessage, toolCall::agentToolCall, config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + signal::abortSignal, + agentEventSink )::Union{preparedToolCall,immediateOutcome} - + agentEventSink("prepareToolCall 1") tool = get(context.tools, toolCall.name, nothing) if tool === nothing + agentEventSink("prepareToolCall 2") return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end try + agentEventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) + agentEventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) - + agentEventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing + agentEventSink("prepareToolCall 6") + before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) - if signal !== nothing && signal.aborted + agentEventSink("prepareToolCall 7") + if signal.aborted + agentEventSink("prepareToolCall 8") return immediateOutcome(createErrorToolResult("Operation aborted"), true) end + if before !== nothing && before.block + agentEventSink("prepareToolCall 9") return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end - + agentEventSink("prepareToolCall 10") return preparedToolCall(tool, toolCall, validatedArgs) - catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + agentEventSink(err_msg) + + return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end end @@ -836,31 +940,31 @@ executePreparedToolCall(prep, nothing, emit) function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, - emit::Function, -)::executedOutcome + agentEventSink, + )::executedOutcome + agentEventSink("executePreparedToolCall 1") + updateEvents = promise[] + accepting = true - updateEvents = promise[] - accepting = true - - try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, - emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - accepting = false - wait.(updateEvents) - return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end + try + result = prep.tool.execute( + prep.toolCall.id, prep.args, signal, + partialResult -> begin + if accepting + push!(updateEvents, + agentEventSink(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + prep.toolCall.arguments, partialResult))) + end + end + ) + accepting = false + wait.(updateEvents) + return executedOutcome(result, false) + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end end # ── per-call finalization ─────────────────────────────────────── @@ -1011,36 +1115,41 @@ function executeToolCallsSequential( assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, + signal::abortSignal, + agentEventSink, )::agentToolCallBatch + agentEventSink("executeToolCallsSequential 1") + finalizedCalls = finalizedOutcome[] + messages = toolResultMessage[] - finalizedCalls = finalizedOutcome[] - messages = toolResultMessage[] - - for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - else - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - end - - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - - if signal !== nothing && signal.aborted - break - end - end - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) + for tc in toolCalls + agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + agentEventSink("executeToolCallsSequential 2") + if prep isa immediateOutcome + agentEventSink("executeToolCallsSequential 2-1") + finalized = finalizedOutcome(tc, prep.result, prep.isError) + agentEventSink("executeToolCallsSequential 2-2") + else + agentEventSink("executeToolCallsSequential 3") + executed = executePreparedToolCall(prep, signal, agentEventSink) + agentEventSink("executeToolCallsSequential 3-1") + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, + signal) + agentEventSink("executeToolCallsSequential 3-2") + end + agentEventSink("executeToolCallsSequential 4") + agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), + $(finalized.result), $(finalized.isError)") + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + agentEventSink("executeToolCallsSequential 5") + if signal !== nothing && signal.aborted + break + end + end + agentEventSink("executeToolCallsSequential 6") + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end # ── parallel execution ────────────────────────────────────────── @@ -1105,51 +1214,51 @@ function executeToolCallsParallel( assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, + signal::abortSignal, + agentEventSink, )::agentToolCallBatch - entries = union{finalizedOutcome,task{finalizedOutcome}}[] + entries = union{finalizedOutcome,task{finalizedOutcome}}[] - for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + for tc in toolCalls + agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal) - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) - push!(entries, finalized) - else - task = task() do - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) - return finalized - end - schedule(task) - push!(entries, task) - end - - if signal !== nothing && signal.aborted - break - end + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) + push!(entries, finalized) + else + task = task() do + executed = executePreparedToolCall(prep, signal, agentEventSink) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) + return finalized + end + schedule(task) + push!(entries, task) end - finalizedCalls = finalizedOutcome[] - for entry in entries - outcome = entry isa task ? fetch(entry) : entry - push!(finalizedCalls, outcome) + if signal !== nothing && signal.aborted + break end + end - messages = toolResultMessage[] - for f in finalizedCalls - push!(messages, createToolResultMessage(f)) - end + finalizedCalls = finalizedOutcome[] + for entry in entries + outcome = entry isa task ? fetch(entry) : entry + push!(finalizedCalls, outcome) + end - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) + messages = toolResultMessage[] + for f in finalizedCalls + push!(messages, createToolResultMessage(f)) + end + + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end @@ -1207,24 +1316,29 @@ function executeToolCalls( assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, -)::agentToolCallBatch + signal::abortSignal, + agentEventSink, + )::agentToolCallBatch - hasSequential = false - for tc in toolCalls - t = get(context.tools, tc.name, nothing) - if t !== nothing && !t.parallelToolExecute - hasSequential = true - break - end - end - - if config.toolExecution == "sequential" || hasSequential - return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) - else - return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) - end + agentEventSink("_executeToolCalls 1") + hasSequential = false + for tc in toolCalls + t = get(context.tools, tc.name, nothing) + if t !== nothing && !t.parallelToolExecute + hasSequential = true + break + end + end + agentEventSink("_executeToolCalls 2") + if config.toolExecution == "sequential" || hasSequential + agentEventSink("_executeToolCalls 3") + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, + agentEventSink) + else + agentEventSink("_executeToolCalls 4") + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, + agentEventSink) + end end diff --git a/src/type.jl b/src/type.jl index 9b8d9bd..b6bf8a9 100644 --- a/src/type.jl +++ b/src/type.jl @@ -146,7 +146,8 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en function assistantMessage(; role="assistant", content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn", errorMessage=nothing, timestamp=now()) - return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) + model_str = model isa AbstractString ? String(model) : "" + return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp) end struct toolResultMessage <: agentMessage # Result returned from a tool execution @@ -395,13 +396,11 @@ end Configuration for the agent tool execution loop. # Arguments -- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `toolExecution::String`: Execution mode — "sequential" or "parallel" """ struct agentLoopConfig - tools::OrderedDict{String, agentTool} beforeToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing} toolExecution::String diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl index 3d97fee..94abd46 100644 --- a/test/_extractToolCalls.jl +++ b/test/_extractToolCalls.jl @@ -9,9 +9,9 @@ import YiemAgent.agentCore: _extractToolCalls @testset "_extractToolCalls" begin - # -------------------------------------------------------------- # - # Format 1: response["message"]["tool_calls"] (LMStudio.jl style) # - # -------------------------------------------------------------- # + # ------------------------------------------------------------------ # + # Format 1: response["message"]["tool_calls"] (LMStudio.jl style) # + # ------------------------------------------------------------------ # @testset "single tool call via message format" begin response = Dict{String,Any}( @@ -33,12 +33,19 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getWeather" @test tc_list[1].id == "tc_001" + @test tc_list[1].type == "function" @test tc_list[1].arguments["city"] == "Bangkok, Thailand" + @test assistant_msg isa assistantMessage + @test assistant_msg.role == "assistant" + @test assistant_msg.stopReason == "tool_calls" + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1] isa textContent + @test assistant_msg.content[1].text == "Let me check the weather." end @testset "multiple tool calls via message format" begin @@ -66,13 +73,14 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 2 @test tc_list[1].name == "getWeather" @test tc_list[1].arguments["city"] == "Tokyo, Japan" @test tc_list[2].name == "getTime" @test tc_list[2].arguments["timezone"] == "Asia/Tokyo" + @test assistant_msg.role == "assistant" end @testset "tool call with empty arguments string" begin @@ -91,11 +99,12 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "listTools" @test tc_list[1].arguments == Dict{String,Any}() + @test assistant_msg.stopReason == "end_turn" end @testset "tool call with missing id falls back to uuid" begin @@ -113,14 +122,14 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test !isempty(tc_list[1].id) @test tc_list[1].name == "getTime" end - @testset "tool call with non-string arguments (pre-parsed)" begin + @testset "tool call with non-string arguments (pre-parsed dict)" begin response = Dict{String,Any}( "message" => Dict{String,Any}( "role" => "assistant", @@ -136,16 +145,42 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].arguments["city"] == "London" @test tc_list[1].arguments["units"] == "fahrenheit" end - # ----------------------------------------------------------- # - # Format 2: response.content blocks (OpenAI API style) # - # ----------------------------------------------------------- # + @testset "tool call with api/provider/model/usage metadata" begin + response = Dict{String,Any}( + "api" => "openai", + "provider" => "anthropic", + "model" => "claude-3-opus", + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{}", + ), + "id" => "tc_meta", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test assistant_msg.api == "openai" + @test assistant_msg.provider == "anthropic" + @test assistant_msg.model == "claude-3-opus" + end + + # --------------------------------------------------------------- # + # Format 2: response.content blocks (OpenAI API style) # + # --------------------------------------------------------------- # @testset "content blocks with tool_calls" begin response = Dict{String,Any}( @@ -166,11 +201,14 @@ import YiemAgent.agentCore: _extractToolCalls ), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getWeather" @test tc_list[1].arguments["city"] == "Paris" + # text block before tool_calls should be included in content + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "Let me check." end @testset "content blocks with tool_call (single-call format)" begin @@ -184,7 +222,7 @@ import YiemAgent.agentCore: _extractToolCalls ), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getTime" @@ -192,9 +230,90 @@ import YiemAgent.agentCore: _extractToolCalls @test tc_list[1].arguments["timezone"] == "Europe/London" end - # ----------------------------------------------------------- # - # Format 2 via struct-like object (no .content field) # - # ----------------------------------------------------------- # + @testset "content blocks with reasoning and text" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "reasoning", "text" => "Thinking..."), + Dict{String,Any}("type" => "text", "text" => "Here's the answer."), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1].text == "Thinking..." + @test assistant_msg.content[2].text == "Here's the answer." + end + + @testset "content blocks with text and tool_call (tool_call not in content)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Sure, I'll check."), + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_mix", + "name" => "getWeather", + "arguments" => Dict{String,Any}("city" => "London"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + # text block included, tool_call block excluded from content + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "Sure, I'll check." + end + + # ------------------------------------------------------------------ # + # assistantMessage construction # + # ------------------------------------------------------------------ # + + @testset "assistantMessage with error_message and errorMessage fallback" begin + response = Dict{String,Any}( + "error_message" => "rate limit", + "content" => Any[Dict{String,Any}("type" => "text", "text" => "fail")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test assistant_msg.errorMessage == "rate limit" + end + + @testset "assistantMessage with usage tracking" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")], + "usage" => llmUsage(100, 50), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.usage.inputTokens == 100 + @test assistant_msg.usage.outputTokens == 50 + end + + @testset "assistantMessage with invalid usage defaults to zero" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")], + "usage" => "invalid", + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.usage.inputTokens == 0 + @test assistant_msg.usage.outputTokens == 0 + end + + @testset "reasoning_content as textContent" begin + response = Dict{String,Any}( + "reasoning_content" => textContent("internal thought"), + "content" => Any[Dict{String,Any}("type" => "text", "text" => "output")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1].text == "internal thought" + @test assistant_msg.content[2].text == "output" + end + + # ------------------------------------------------------------------ # + # No tool call cases # + # ------------------------------------------------------------------ # @testset "no tool calls found" begin response = Dict{String,Any}( @@ -202,16 +321,20 @@ import YiemAgent.agentCore: _extractToolCalls Dict{String,Any}("type" => "text", "text" => "Hello world."), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == false @test length(tc_list) == 0 + @test assistant_msg.stopReason == "end_turn" end @testset "empty message" begin response = Dict{String,Any}() - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == false @test length(tc_list) == 0 + @test assistant_msg.role == "assistant" + @test assistant_msg.stopReason == "end_turn" + @test length(assistant_msg.content) == 0 end @testset "message with empty tool_calls array" begin @@ -221,9 +344,21 @@ import YiemAgent.agentCore: _extractToolCalls "tool_calls" => Any[], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == false @test length(tc_list) == 0 + @test assistant_msg.role == "assistant" + end + + @testset "message field is not a Dict" begin + response = Dict{String,Any}( + "message" => "not a dict", + "content" => Any[Dict{String,Any}("type" => "text", "text" => "fallback")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 1 end @testset "Format 1 takes priority over Format 2" begin @@ -250,15 +385,15 @@ import YiemAgent.agentCore: _extractToolCalls ), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getWeather" end - # ----------------------------------------------------------- # - # edge cases # - # ----------------------------------------------------------- # + # ------------------------------------------------------------------ # + # Edge cases # + # ------------------------------------------------------------------ # @testset "tool call with null arguments" begin response = Dict{String,Any}( @@ -276,7 +411,7 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getTime" @@ -294,7 +429,7 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "" @@ -313,7 +448,7 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "" @@ -333,11 +468,171 @@ import YiemAgent.agentCore: _extractToolCalls ), )) parsed = JSON.parse(json_str) - has_toolcalls, tc_list = _extractToolCalls(parsed) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(parsed) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getWeather" @test tc_list[1].arguments["city"] == "Test" end + @testset "tool_calls block with mixed content types (text + tool_calls)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "I'll check both."), + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"London\"}", + ), + "id" => "tc_mix1", + ), + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{\"timezone\":\"UTC\"}", + ), + "id" => "tc_mix2", + ), + ], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 2 + @test tc_list[1].name == "getWeather" + @test tc_list[2].name == "getTime" + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "I'll check both." + end + + @testset "tool_call block without arguments field" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_noargs", + "name" => "getTime", + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].arguments == Dict{String,Any}() + end + + @testset "tool_calls block with empty tool_calls array" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any[], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "tool_calls block with non-AbstractDict elements" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any["not a dict", 42, nothing], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "content field is not a Vector" begin + response = Dict{String,Any}( + "content" => "not a vector", + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 0 + end + + @testset "Dict-based response with all metadata fields" begin + response = Dict{String,Any}( + "api" => "openai", + "provider" => "anthropic", + "model" => "claude-3-sonnet", + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_meta", + "name" => "getTime", + "arguments" => Dict{String,Any}("city" => "Seoul"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getTime" + @test tc_list[1].arguments["city"] == "Seoul" + @test assistant_msg.api == "openai" + @test assistant_msg.provider == "anthropic" + @test assistant_msg.model == "claude-3-sonnet" + end + + @testset "default role is assistant" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "no role specified")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.role == "assistant" + end + + @testset "tool call with custom role in message format" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "custom_role", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{}", + ), + "id" => "tc_role", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.role == "custom_role" + end + + @testset "image content block handling" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "image_url", + "image_url" => Dict("url" => "data:image/png;base64,abc123"), + ), + Dict{String,Any}("type" => "text", "text" => "What is this?"), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1] isa textContent + @test assistant_msg.content[1].text == "" + @test assistant_msg.content[2].text == "What is this?" + end + end -- 2.52.0 From 2543e6cbf1d5d3f4a786ee50bf12221a6f4e8cde Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 15 Aug 2026 16:50:28 +0700 Subject: [PATCH 086/112] static tool loading --- Manifest.toml | 51 +++- Project.toml | 2 + README_tools.md | 544 +++++++++++++++++++--------------------- src/YiemAgent.jl | 15 +- src/agentCore.jl | 221 +++++++++------- src/toolRegistry.jl | 100 +------- src/tools/getTime.jl | 13 +- src/tools/getWeather.jl | 33 ++- src/tools/writeTool.jl | 21 +- src/type.jl | 4 +- src/utils.jl | 60 ++++- test/toolTest.jl | 229 ++++++++++++++--- user_code.jl | 113 +++++++++ 13 files changed, 864 insertions(+), 542 deletions(-) create mode 100644 user_code.jl diff --git a/Manifest.toml b/Manifest.toml index 2e462e1..0bbbd2e 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c" +project_hash = "aa163e2bf572632825162936e107be18384fd40f" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6" uuid = "ec485272-7323-5ecc-a04f-4719b315124d" version = "0.4.0" +[[deps.Arrow]] +deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"] +git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01" +uuid = "69666777-d1a9-59fb-9406-91d4454c9d45" +version = "2.8.1" + [[deps.ArrowTypes]] deps = ["Sockets", "UUIDs"] git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" @@ -58,6 +64,12 @@ version = "1.11.0" uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" version = "1.11.0" +[[deps.BitIntegers]] +deps = ["Random"] +git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46" +uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1" +version = "0.3.7" + [[deps.BufferedStreams]] git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643" uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d" @@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a" uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" version = "0.3.4" +[[deps.CodecLz4]] +deps = ["Lz4_jll", "TranscodingStreams"] +git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2" +uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561" +version = "0.4.6" + [[deps.CodecZlib]] deps = ["TranscodingStreams", "Zlib_jll"] git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" uuid = "944b1d66-785c-5afd-91f1-9de20f533193" version = "0.7.8" +[[deps.CodecZstd]] +deps = ["TranscodingStreams", "Zstd_jll"] +git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56" +uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" +version = "0.8.7" + [[deps.CommonSolve]] git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" @@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"] [deps.CompositionsBase.extensions] CompositionsBaseInverseFunctionsExt = "InverseFunctions" +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.6.0" + [[deps.ConstructionBase]] git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" @@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" version = "3.8.0" +[[deps.Lz4_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33" +uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" +version = "1.10.1+0" + [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" @@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" version = "0.4.7" +[[deps.StringViews]] +git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8" +uuid = "354b36f9-a18e-4713-926e-db85100087ba" +version = "1.3.7" + [[deps.StructTypes]] deps = ["Dates", "UUIDs"] git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" @@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026" uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" version = "1.0.21+0" +[[deps.msghandler]] +deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"] +git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f" +repo-rev = "main" +repo-url = "https://git.yiem.cc/ton/msghandler" +uuid = "f2724d33-f338-4a57-b9f8-1be882570d10" +version = "1.2.1" + [[deps.nghttp2_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" diff --git a/Project.toml b/Project.toml index f687431..7fea2af 100644 --- a/Project.toml +++ b/Project.toml @@ -22,6 +22,7 @@ SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10" [compat] Base64 = "1.11.0" @@ -33,3 +34,4 @@ JSON = "1.6.1" LLMMCTS = "0.1.5" NATS = "0.1.0" SQLLLM = "0.2.8" +msghandler = "1.2.1" diff --git a/README_tools.md b/README_tools.md index de61209..9560f36 100644 --- a/README_tools.md +++ b/README_tools.md @@ -9,7 +9,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework, 1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) 2. [Overview](#2-overview) 3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) -4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores) +4. [Tool Registration — Static Registration](#4-tool-registration--static-registration) 5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) 6. [Message Processing Pipeline](#6-message-processing-pipeline) 7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) @@ -23,7 +23,8 @@ This document describes the complete tool lifecycle in the YiemAgent framework, 15. [Self-Modifying Tools](#15-self-modifying-tools) 16. [Complete End-to-End Example](#16-complete-end-to-end-example) 17. [Tool File Contract](#17-tool-file-contract) -18. [Appendix: Type Reference](#18-appendix-type-reference) +18. [Adding New Tools](#18-adding-new-tools) +19. [Appendix: Type Reference](#19-appendix-type-reference) --- @@ -43,76 +44,52 @@ tool = listTool(store) # Returns an agentTool that, when executed, lists all to **Result extraction:** ```julia result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) -# result.content[1].text => "Available tools:\n- getTime: Time Lookup — Get current local time...\n- getWeather: Weather Lookup — Fetch current weather..." +# result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..." ``` -**Source:** `toolRegistry.jl:54-82` +**Source:** `toolRegistry.jl:43-98` --- -### Step 2: Load — `loadTools()` +### Step 2: Register — `register_all_tools()` -Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools. +Tools are statically defined in `src/tools/` and registered at module initialization via `register_all_tools()`. Each tool function (e.g., `getWeatherTool()`, `getTimeTool()`, `writeToolTool()`) is called to create the `agentTool` struct. `listTool` is auto-registered so the LLM can discover available tools. ```julia using YiemAgent, YiemAgent.toolRegistry store = toolStore(name="myAgent") -tools = loadTools(store, "src/tools") -# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools +tools = register_all_tools(store) +# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs # Also auto-registers listTools for runtime discovery ``` **Result extraction:** ```julia all_tools = getTools(store) # OrderedDict{String, agentTool} -# Keys: "getTime", "getWeather", "writeTool", "listTools" -getTime_tool = all_tools["getTime"] +# Keys: "getWeather", "getTime", "writeTool", "listTools" +getWeather_tool = all_tools["getWeather"] -# Manual registration (alternative to loadTools) +# Manual registration (alternative to register_all_tools) registerTool(store, my_tool) clearTools(store) # Clear all tools from store ``` -**Source:** `toolRegistry.jl:126-178` +**Source:** `toolRegistry.jl:38-40, 127-143` --- ### Step 2.5: Create Agent with Tools -Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is deep-copied into `agent._state.tools`; `_tool_store` is kept for runtime registration. +Wire the registered tools into a new `yiemAgent` instance. The `yiemAgent` constructor calls `register_all_tools()` automatically. ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry -# 1. Set up toolStore and load tools (auto-registers listTools) -store = toolStore(name="myAgent") -loadTools(store, "src/tools") - -# 2. Create agent — pass tools + _tool_store +# Create agent — tools are registered automatically via register_all_tools() agent = yiemAgent( - systemPrompt = "You are a helpful assistant.", - model = my_model, - tools = getTools(store), # OrderedDict{String, agentTool} llmCall = my_llm_call, # Function that calls the LLM API agentEventSink = my_event_sink, # Function for TUI/logging - _tool_store = store, # For runtime registerTool() calls -) -``` - -**Manual registration** (without `loadTools`): - -```julia -store = toolStore(name="myAgent") -registerTool(store, getTime_tool) -registerTool(store, getWeather_tool) -registerTool(store, listTool(store)) # needed for manual registration - -agent = yiemAgent( - tools = getTools(store), - llmCall = my_llm_call, - agentEventSink = my_event_sink, - _tool_store = store, ) ``` @@ -120,17 +97,15 @@ agent = yiemAgent( | Parameter | Type | Required | Purpose | |-----------|------|----------|---------| -| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | -| `model` | `llmModel` | No | LLM model config | -| `tools` | `OrderedDict{String, agentTool}` | No | Available tools (deep-copied) | -| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | | `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | -| `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` | +| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | +| `model` | `llmModel` | No | LLM model config | +| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. -**Source:** `type.jl:609-657`, `toolRegistry.jl:38-40, 191-195` +**Source:** `type.jl:609-657`, `toolRegistry.jl:127-143` --- @@ -146,13 +121,13 @@ sig = nothing op = x -> x # no-op partial result callback # Execute a loaded tool directly -result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) +result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) ``` **Via agent loop (production):** ``` user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) - → _agentLoop detects message → @spawn _process_message(agent) + → _agentLoop detects message → @spawn _processMessage(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls → executeToolCalls(context, response, tool_call_list, config, signal, emit) @@ -168,10 +143,10 @@ user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) **`agentToolResult`** (raw tool output, `type.jl:429-434`): ```julia -result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) +result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) -result.content[1] # textContent("Current time in Tokyo: ...") -result.content[1].text # "Current time in Tokyo: 2026-08-10T..." +result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C") +result.content[1].text # "Weather in Tokyo: Sunny, 22°C" result.details # Dict{Any,Any}() — tool-specific metadata result.usage # nothing — llmUsage tracking (optional) result.terminate # false — signals loop termination @@ -182,7 +157,7 @@ result.terminate # false — signals loop termination msg = batch.messages[1] # toolResultMessage msg.toolCallId # "call-1" -msg.toolName # "getTime" +msg.toolName # "getWeather" msg.content # Vector{messageContent} msg.isError # false msg.details # tool-specific metadata @@ -204,7 +179,7 @@ Each phase has a single responsibility and produces an intermediate result: | Phase | Function | Input | Output | Purpose | |-------|----------|-------|--------|---------| | Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | -| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `emit` | `executedOutcome` | Call `tool.execute()`, stream partial results | +| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results | | Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. @@ -276,9 +251,9 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca --- -## 4. Tool Registration — Per-Agent Tool Stores +## 4. Tool Registration — Static Registration -**Source:** `toolRegistry.jl` +**Source:** `toolRegistry.jl`, `YiemAgent.jl` ### How `toolStore` Works @@ -293,36 +268,25 @@ end `store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. -### How `loadTools(store, dir)` Works +### Static Registration — `register_all_tools()` ```julia -function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} +function register_all_tools(store::toolStore)::OrderedDict{String, agentTool} ``` -**Source:** `toolRegistry.jl:126-178` +**Source:** `YiemAgent.jl:20-29` -1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) -2. **Sorts** filenames alphabetically for deterministic registration order -3. **Wraps** each file in a dynamically created submodule: - ```julia - # For "getWeather.jl" → module _tool_getWeather - module _tool_getWeather - using ..type - using Dates, UUIDs, DataStructures, JSON - # (file contents here) - end - ``` -4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues -5. **Validates** the return value is an `agentTool` instance -6. **Registers** the tool in `store.tools` -7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime +1. **Calls each tool's definition function** — `getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs +2. **Registers each tool** via `registerTool(store, tool)` +3. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime -### Why Submodules? +### Why Static? -Each tool file is loaded into its own **namespaced submodule**. This means: -- `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` -- No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` -- The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected +Tools are **statically included** in `YiemAgent.jl` via `include()`. This means: +- Tool functions live in the `YiemAgent` module, not in dynamically created submodules +- No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world) +- Simpler tool definition — no need to wrap in a `module ... end` block +- Better compiler optimization (inlining, type inference) ### Registration API @@ -331,9 +295,9 @@ Each tool file is loaded into its own **namespaced submodule**. This means: store1 = toolStore(name="agent1") store2 = toolStore(name="agent2") -# Load tools into specific stores (auto-registers listTools) -tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only -tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only +# Load all tools (auto-registers listTools) +tools1 = register_all_tools(store1) # all agents get the same tools +tools2 = register_all_tools(store2) # Manual registration (per-store) registerTool(store1, my_tool) @@ -359,8 +323,8 @@ Each `toolStore` is completely independent — tools registered in one store do storeA = toolStore(name="A") storeB = toolStore(name="B") -registerTool(storeA, getTime_tool) -registerTool(storeB, getWeather_tool) +registerTool(storeA, getTimeTool()) +registerTool(storeB, getWeatherTool()) getTools(storeA) # only contains getTime getTools(storeB) # only contains getWeather @@ -385,7 +349,6 @@ yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() - - _tool_store (toolStore) ← per-agent isolated tool registry ``` ### Loop States @@ -395,7 +358,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`): | State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | |-------|-----------------|-------------|----------------|-------------------|----------| | 1 | `nothing` | `false` | empty | empty | Idle, waiting | -| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_process_message` | +| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_processMessage` | | 3 | running | `true` | empty | empty | Processing, no new input | | 4 | running | `true` | has msg | empty | New message while busy → queued | | 5 | running | `true` | empty | has msg | Follow-up while busy → queued | @@ -414,9 +377,9 @@ function _agentLoop(agent::yiemAgent) drain both channels, break loop end - # 3. If agent is idle, spawn _process_message + # 3. If agent is idle, spawn _processMessage if agent._state.activeRun == false - processingTask = Threads.@spawn _process_message(agent) + processingTask = Threads.@spawn _processMessage(agent) agent._state.activeRun = true end @@ -445,12 +408,12 @@ end **Source:** `agentCore.jl:175-311` -`_process_message(agent)` is the core function that processes a batch of user messages through the LLM pipeline. +`_processMessage(agent)` is the core function that processes a batch of user messages through the LLM pipeline. ### Pipeline Steps ```julia -function _process_message(agent::yiemAgent)::assistantMessage +function _processMessage(agent::yiemAgent)::assistantMessage final_response = nothing while true # Loop until LLM returns response without tool calls @@ -466,38 +429,38 @@ function _process_message(agent::yiemAgent)::assistantMessage end # ── Step 2: Prepare context ───────────────────────────────── - preparedContext = agent.prepareContext(agent._state) + state = agentState(systemPrompt, nothing, tools, messages) + preparedContext = prepareContext(state, agentEventSink) # Default: deep copies systemPrompt, messages, tools from agentState → agentContext # Override point: filter tools, inject context, modify system prompt # ── Step 3: Format for LLM ────────────────────────────────── - formatted_messages = agent.formatMsgForLLM(preparedContext) + formattedMessages = formatMsgForLLM(preparedContext, agentEventSink) # Converts agentContext → Dict("messages" => [...]) in OpenAI format # Wraps systemPrompt as system role, converts each messageContent block # ── Step 4: Call LLM ──────────────────────────────────────── - response = agent.llmCall(formatted_messages) + response = llmCall(formattedMessages) # Returns assistantMessage with content::Vector{messageContent} # Each content block has a type: "text", "thinking", or "tool_call" # ── Step 5: Extract tool calls ────────────────────────────── - has_tool_calls, tool_call_list = extract_tool_calls(response.content) + hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) # Inspects content blocks for "tool_calls" or "tool_call" Dict entries # ── Step 6: Execute tool calls or return ──────────────────── - if has_tool_calls && !isempty(tool_call_list) + if hasToolCalls && !isempty(toolCallList) # Build context and config - context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) - config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...) - signal = nothing - emit = agent.agentEventSink + context = agentContext(systemPrompt, messages, tools) + config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") + signal = abortSignal(false) # Execute tool calls (sequential or parallel) - batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink) # Save results to conversation history for tool_result in batch.messages - push!(agent._state.messages, tool_result) + push!(messages, tool_result) end # Check termination @@ -517,10 +480,6 @@ function _process_message(agent::yiemAgent)::assistantMessage end ``` -### Debug Note - -There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts execution after the LLM call. This appears to be a debugging/staging marker. Remove or replace it before production use. - --- ## 7. Tool Call Extraction from LLM Response @@ -652,51 +611,29 @@ function prepareToolCall( function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing, abortSignal}, - emit::Function, + agentEventSink, )::executedOutcome ``` **Steps:** -1. **Initialize streaming state:** - ```julia - updateEvents = promise[] # vector to collect update event handles - accepting = true # guard to prevent duplicate emissions - ``` +1. **Call `tool.execute()`:** + ```julia + result = prep.tool.execute( + prep.toolCall.id, + prep.args, + signal, + agentEventSink # serves as onPartialResult callback + ) + return executedOutcome(result, false) + ``` -2. **Call `tool.execute()`:** - ```julia - result = prep.tool.execute( - prep.toolCall.id, - prep.args, - signal, - partialResult -> begin - if accepting - push!(updateEvents, - emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - ``` - -3. **Wait for streaming to settle:** - ```julia - accepting = false - wait.(updateEvents) # wait for all pending update event handlers - return executedOutcome(result, false) - ``` - -4. **On error:** - ```julia - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end - ``` - -**Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted. +2. **On error:** + ```julia + catch err + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end + ``` ### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` @@ -716,49 +653,36 @@ function finalizeExecutedToolCall( **Steps:** 1. **Extract execution result:** - ```julia - result = executed.result - isError = executed.isError - ``` + ```julia + result = executed.result + isError = executed.isError + ``` 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - - Hook can mutate the result: - ```julia - after = config.afterToolCall(afterToolCallContext(...)) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - ``` - - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic - - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` + - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Hook can mutate the result: + ```julia + after = config.afterToolCall(afterToolCallContext(...)) + if after !== nothing + result = merge(result, dict( + :content => get(after, :content, result.content), + :details => get(after, :details, result.details), + :usage => get(after, :usage, result.usage), + :terminate => get(after, :terminate, result.terminate) + )) + isError = get(after, :isError, isError) + end + ``` + - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic + - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` 3. **Return:** - ```julia - return finalizedOutcome(prep.toolCall, result, isError) - ``` + ```julia + return finalizedOutcome(prep.toolCall, result, isError) + ``` **Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. -### 8.4 Emission — `emitToolExecutionEnd()` - -**Source:** `agentCore.jl:736-739` - -```julia -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) -end -``` - -This is called immediately after finalization, before building the `toolResultMessage`. - --- ## 9. Execution Modes — Sequential vs Parallel @@ -774,7 +698,7 @@ function executeToolCalls( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::Union{Nothing, abortSignal}, - emit::Function, + agentEventSink, )::agentToolCallBatch ``` @@ -809,18 +733,15 @@ function executeToolCallsSequential(...)::agentToolCallBatch messages = toolResultMessage[] for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) else - executed = executePreparedToolCall(prep, signal, emit) + executed = executePreparedToolCall(prep, signal, agentEventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) end - emitToolExecutionEnd(finalized, emit) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) @@ -842,19 +763,15 @@ function executeToolCallsParallel(...)::agentToolCallBatch entries = union{finalizedOutcome, task{finalizedOutcome}}[] for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) - emitToolExecutionEnd(finalized, emit) push!(entries, finalized) # immediate outcome — no task else task = task() do - executed = executePreparedToolCall(prep, signal, emit) + executed = executePreparedToolCall(prep, signal, agentEventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emitToolExecutionEnd(finalized, emit) return finalized end schedule(task) @@ -923,12 +840,12 @@ From the type documentation (`type.jl:803-815`): | Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | | Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | -### Batch Processing in `_process_message()` +### Batch Processing in `_processMessage()` **Source:** `agentCore.jl:266-307` ```julia -batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) +batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) # Save results to conversation history for tool_result in batch.messages @@ -985,9 +902,9 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage f.result.content, # content (Vector{messageContent}) f.result.details, # details f.result.usage, # usage - get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools) + nothing, # addedToolNames (for dynamic tools) f.isError, # isError - nowMillis(), # timestamp + now(), # timestamp ) end ``` @@ -1048,9 +965,6 @@ Tool call fails at any phase └─────────────────────────┘ │ ▼ -emitToolExecutionEnd(finalized, emit) - │ - ▼ createToolResultMessage(finalized) │ ▼ @@ -1119,14 +1033,14 @@ end |-------|-------------|------| | `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | | `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | -| `toolExecEndEvent` | `emitToolExecutionEnd()` | After `finalizeExecutedToolCall()` for each tool call | +| `toolExecEndEvent` | `finalizeExecutedToolCall()` | After finalization for each tool call | ### Event Sink -The `emit` function is passed through the entire call chain: +The `agentEventSink` function is passed through the entire call chain: ```julia -emit = agent.agentEventSink # set during yiemAgent construction +agentEventSink = agent.agentEventSink # set during yiemAgent construction ``` The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by: @@ -1143,8 +1057,8 @@ The `agentEventSink` function is a user-provided callback that receives all even | Hook | Signature | Called | Purpose | |------|-----------|--------|---------| -| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | -| `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | +| `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | +| `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | | `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | @@ -1211,7 +1125,7 @@ end **Source:** `utils.jl:111-125` ```julia -function prepareContext(state::agentState)::agentContext +function prepareContext(state::agentState, agentEventSink)::agentContext # TODO: filter tools from state.tools based on user intent filteredTools = state.tools @@ -1238,7 +1152,7 @@ end Default implementation converts `agentContext` to OpenAI-compatible format: ```julia -function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} messages = Vector{Dict{String, Any}}() # System prompt as system message @@ -1280,14 +1194,14 @@ The framework supports tools that modify the tool system itself at runtime. 1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal 2. Indents `executeCode` with 4 spaces 3. Wraps it inside `function executeTool(...)::agentToolResult ... end` -4. Appends `getTool()` returning an `agentTool` struct +4. Appends `writeToolTool()` returning an `agentTool` struct 5. Writes the combined string to `src/tools/.jl` ### `listTool` — Discover Available Tools -**Source:** `toolRegistry.jl:55-82` +**Source:** `toolRegistry.jl:43-98` -Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. +Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow @@ -1301,9 +1215,11 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - (optional) validateCode, prepareCode 3. writeTool generates src/tools/searchWine.jl -4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file -5. Agent calls searchWine(query="cabernet") -6. Result: "Found 5 cabernet wines..." +4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl +5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl +6. Developer restarts Julia — new tool is loaded +7. Agent calls searchWine(query="cabernet") +8. Result: "Found 5 cabernet wines..." ``` ### `writeTool` Input Schema @@ -1328,14 +1244,14 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT ``` USER SENDS MESSAGE └─> runAgent(agent, "What's the weather in Tokyo?") - └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) + └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL └─> _agentLoop: detects msg in inputChannel - └─> Threads.@spawn _process_message(agent) + └─> @spawn _processMessage(agent) - ── _process_message ────────────────────────────────────────────── + ── _processMessage ────────────────────────────────────────────── │ │ Step 1: Drain inputChannel │ raw_msg = Dict("role" => "user", "content" => [...]) @@ -1367,36 +1283,34 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL │ │ Step 6: Execute tool calls │ context = agentContext(systemPrompt, messages, tools) - │ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential") - │ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit) + │ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") + │ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) + + +LOOP ITERATION 1 — executeToolCallsSequential │ - │ ── executeToolCallsSequential ────────────────────────────── - │ │ - │ │ For tc = agentToolCall("call_1", "getWeather", ...): - │ │ - │ │ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"})) - │ │ - │ │ PREPARE: - │ │ tool = context.tools["getWeather"] → found! - │ │ validatedArgs = validateToolArguments(tool, tc) - │ │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes - │ │ beforeToolCall_hook(...) → nothing (skipped) - │ │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) - │ │ - │ │ EXECUTE: - │ │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult) - │ │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) - │ │ → executedOutcome(result, false) - │ │ - │ │ FINALIZE: - │ │ afterToolCall_hook(...) → nothing (skipped) - │ │ → finalizedOutcome(tc, result, false) - │ │ - │ │ emit(toolExecEndEvent("call_1", "getWeather", result, false)) - │ │ msg = createToolResultMessage(finalized) - │ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) - │ │ - │ └─> agentToolCallBatch([msg], false) + │ For tc = agentToolCall("call_1", "getWeather", ...): + │ + │ PREPARE: + │ tool = context.tools["getWeather"] → found! + │ validatedArgs = validateToolArguments(tool, tc) + │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes + │ beforeToolCall_hook(...) → nothing (skipped) + │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) + │ + │ EXECUTE: + │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink) + │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) + │ → executedOutcome(result, false) + │ + │ FINALIZE: + │ afterToolCall_hook(...) → nothing (skipped) + │ → finalizedOutcome(tc, result, false) + │ + │ msg = createToolResultMessage(finalized) + │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) + │ + └─> agentToolCallBatch([msg], false) │ │ Save results: │ for tool_result in batch.messages @@ -1408,7 +1322,7 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE - ── _process_message (second iteration) ─────────────────────────── + ── _processMessage (second iteration) ─────────────────────────── │ │ Step 1: Drain inputChannel → empty │ @@ -1437,12 +1351,12 @@ AGENT LOOP: SEND RESPONSE TO USER ## 17. Tool File Contract -Each `.jl` file in `src/tools/` must conform to the following contract: +Each `.jl` file in `src/tools/` follows a flat, static structure: ### Required Function ```julia -function getTool()::agentTool +function Tool()::agentTool # Must return an agentTool instance end ``` @@ -1451,22 +1365,22 @@ end ```julia # Argument preparation (before validation) -function prepareArguments(args::Dict{String,Any})::Dict{String,Any} +function PrepareArguments(args::Dict{String,Any})::Dict{String,Any} # Return modified args, or args unchanged return args end # Custom validation (before execution) -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} # Return nothing to pass, or error string to fail return nothing end # Core execution -function executeTool(toolCallId::String, - args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult +function Execute(toolCallId::String, + args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult # Return agentToolResult with content, details, usage, terminate return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false) end @@ -1477,7 +1391,8 @@ end ```julia # src/tools/myTool.jl -using Dates # ← tool declares its own dependencies (registry injects only `using ..type`) +using .type # ← provides agentTool, textContent, agentToolResult, etc. +using Dates # ← tool's own dependencies # Optional: helper functions function helper_function(...) @@ -1485,24 +1400,24 @@ function helper_function(...) end # Optional: prepareArguments -function prepareArguments(args::Dict{String,Any})::Dict{String,Any} +function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any} return args end # Optional: validateRequiredArgs -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end -# Required: executeTool -function executeTool(toolCallId::String, args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult +# Required: execute function +function myToolExecute(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult ... end -# Required: getTool -function getTool()::agentTool +# Required: getTool function +function myToolTool()::agentTool return agentTool( name = "myTool", label = "My Tool", @@ -1512,9 +1427,9 @@ function getTool()::agentTool "properties" => Dict(...), "required" => [...] ), - execute = executeTool, - prepareArguments = prepareArguments, - validateRequiredArgs = validateRequiredArgs, + execute = myToolExecute, + prepareArguments = myToolPrepareArguments, + validateRequiredArgs = myToolValidateRequiredArgs, parallelToolExecute = false ) end @@ -1522,51 +1437,110 @@ end ### Dependencies -Each tool file **declares its own dependencies** via `using` statements at the top of the file. The registry does **not** inject any standard library packages — if a tool needs `Dates`, `JSON`, `HTTP`, `CSV`, or any other package, it must include its own `using` statements. +Each tool file declares its own dependencies via `using` statements: ```julia # src/tools/getTime.jl +using .type using Dates -function executeTool(...) +function getTimeExecute(...) now() # Dates.now requires `using Dates` end ``` ```julia # src/tools/myApiTool.jl +using .type using HTTP, JSON -function executeTool(...) +function myApiToolExecute(...) response = HTTP.get("https://api.example.com") data = JSON.parse(String(response.body)) ... end ``` -### Module Isolation +### Why Flat Modules? -When `loadTools()` loads a file, it wraps it in a dynamically created submodule. The registry injects **only** `using ..type` to make core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`, etc.) available: - -```julia -# User writes in src/tools/myTool.jl: -using Dates, HTTP, JSON # ← tool's own dependencies - -function getTool()::agentTool ... end - -# loadTools() creates: -module _tool_myTool - using ..type # ← injected by registry (core types only) - using Dates, HTTP, JSON # ← from tool file - # (user's code here) -end -``` - -All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive by the function objects stored in `agentTool`, preventing garbage collection of closures. +All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means: +- All functions live in the `YiemAgent` module, avoiding world-age issues +- `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available +- Functions are named with a `` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`) +- The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration --- -## 18. Appendix: Type Reference +## 18. Adding New Tools + +To add a new tool (e.g., `searchWine.jl`): + +### Step 1: Create `src/tools/searchWine.jl` + +```julia +using .type +# using AdditionalPkg # add if needed + +function searchWineExecute(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, onPartialResult) + query = get(args, "query", "") + result = search_wine_db(query) + return agentToolResult( + [textContent("Found $(length(result)) wines")], + Dict{Any,Any}("count" => length(result)), + nothing, false + ) +end + +function searchWineTool()::agentTool + return agentTool( + name = "searchWine", + label = "Search Wine", + description = "Search wine database...", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query") + ), + "required" => ["query"] + ), + execute = searchWineExecute, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +``` + +### Step 2: Include in `src/YiemAgent.jl` (before `toolRegistry.jl`) + +```julia +include("tools/getWeather.jl") +include("tools/getTime.jl") +include("tools/searchWine.jl") # ← add here +include("tools/writeTool.jl") +``` + +### Step 3: Register in `register_all_tools()` in `YiemAgent.jl` + +```julia +function register_all_tools(store::toolRegistry.toolStore) + registerTool(store, getWeatherTool()) + registerTool(store, getTimeTool()) + registerTool(store, searchWineTool()) # ← add here + registerTool(store, writeToolTool()) + registerTool(store, listTool(store)) + return store.tools +end +``` + +### Step 4: Restart Julia + +The module recompiles on next load. The new tool is available immediately. + +--- + +## 19. Appendix: Type Reference ### Message Types @@ -1604,7 +1578,7 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli |------|--------|-------------| | `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | -| `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | +| `agentLoopConfig` | `type.jl:403` | Loop config (beforeToolCall, afterToolCall, toolExecution) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | | `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | | `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 5659d95..11de2b9 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -1,7 +1,6 @@ module YiemAgent - # export agent - + export register_all_tools """ Order by dependencies of each file. The 1st included file must not depend on any other files and each file can only depend on the file included before it. @@ -13,9 +12,21 @@ module YiemAgent include("utils.jl") using .utils + include("tools/getWeather.jl") + include("tools/getTime.jl") + include("tools/writeTool.jl") + include("toolRegistry.jl") using .toolRegistry + function register_all_tools(store::toolRegistry.toolStore) + registerTool(store, getWeatherTool()) + registerTool(store, getTimeTool()) + registerTool(store, writeToolTool()) + registerTool(store, listTool(store)) + return store.tools + end + # include("llmfunction.jl") # using .llmfunction diff --git a/src/agentCore.jl b/src/agentCore.jl index d6e408c..c45945e 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,16 +1,23 @@ module agentCore -export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls +export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls, + executePreparedToolCall, prepareToolCall, executeToolCallsSequential, + executeToolCallsParallel, executeToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads, NATS using GeneralUtils using ..type, ..utils, ..toolRegistry +function register_all_tools(store::toolRegistry.toolStore) + # Call parent module's version which has access to tool functions + parentmodule(@__MODULE__).register_all_tools(store) +end + # ---------------------------------------------- 100 --------------------------------------------- # """ -docstring + docstring """ mutable struct yiemAgent <: agent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) @@ -84,7 +91,6 @@ on `inputChannel` and `followUpChannel` channels concurrently. - A new `yiemAgent` instance with an active background task """ function yiemAgent( - toolsFolderPath::String, llmCall, ; systemPrompt::String="You are helpful assistant.", @@ -106,9 +112,9 @@ function yiemAgent( followUp = Channel(32) outputChannel = Channel(16) - # load tools from toolsFolderPath + # load tools (statically registered at module init) toolStore1 = toolStore(name="myagent") - loadTools(toolStore1, toolsFolderPath) + register_all_tools(toolStore1) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( @@ -291,6 +297,8 @@ function _agentLoop(agent::yiemAgent) processingTask = nothing # reset end agent.agentEventSink("_agentLoop 6") + agent.agentEventSink(string(typeof(processingTask))) + agent.agentEventSink("_agentLoop 7") end catch e # On any error, send error response and exit the loop @@ -329,7 +337,7 @@ julia> # Currently returns a placeholder echo response function _processMessage( inputChannel::Channel, agentEventSink, - messages::Vector{agentMessage}, + agentMsgHistory::Vector{agentMessage}, systemPrompt::String, tools::OrderedDict{String, agentTool}, prepareContext::Function, @@ -370,12 +378,12 @@ function _processMessage( end agentEventSink("_processMessage 5") user_msg = OpenAiToUserMessage(raw_msg) - push!(messages, user_msg) + push!(agentMsgHistory, user_msg) agentEventSink("_processMessage 6") end agentEventSink("_processMessage 7") # call prepareContext() - state = agentState(systemPrompt, nothing, tools, messages) + state = agentState(systemPrompt, nothing, tools, agentMsgHistory) agentEventSink("_processMessage 8") preparedContext = prepareContext(state, agentEventSink) agentEventSink("_processMessage 8") @@ -396,7 +404,7 @@ function _processMessage( agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") if hasToolCalls && length(toolCallList) > 0 - #WORKING Build context and config for executeToolCalls + # Build context and config for executeToolCalls config = agentLoopConfig( beforeToolCall, @@ -408,23 +416,21 @@ function _processMessage( agentEventSink("_processMessage 12") # call executeToolCalls() - - batch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, - agentEventSink) - - + toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, + signal, agentEventSink) agentEventSink("_processMessage 13") - error("debug marker") - # save toolResults to messages - for tool_result in batch.messages - push!(messages, tool_result) - end - if batch.terminate - # If batch requested termination, build a final response + # save toolResults to messages + for toolResult in toolResultBatch.messages + push!(agentMsgHistory, toolResult) + end + agentEventSink("_processMessage 14") + if toolResultBatch.terminate + agentEventSink("_processMessage 15") + # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] - for tool_result in batch.messages - for content_block in tool_result.content + for toolResult in toolResultBatch.messages + for content_block in toolResult.content if content_block isa textContent append!(final_content, [content_block]) elseif content_block isa Dict @@ -434,6 +440,7 @@ function _processMessage( end end end + agentEventSink("_processMessage 16") final_response = assistantMessage( role="assistant", content=final_content, @@ -441,7 +448,7 @@ function _processMessage( model=assistant_msg.model, usage=assistant_msg.usage, stopReason="tool_use_terminated", - errorMessage=if any(x -> x.isError, batch.messages) + errorMessage=if any(x -> x.isError, toolResultBatch.messages) "One or more tool calls failed" else nothing @@ -451,12 +458,13 @@ function _processMessage( break end else + agentEventSink("_processMessage 17") # LLM did not use tool calls — this is the final response final_response = assistant_msg break end end - + agentEventSink("_processMessage 18") return final_response end @@ -524,7 +532,7 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage return toolResultMessage( "toolResult", f.toolCall.id, f.toolCall.name, f.result.content, f.result.details, f.result.usage, - get(f.result, :addedToolNames, string[]), f.isError, nowMillis() + nothing, f.isError, now() ) end @@ -563,8 +571,10 @@ function _extractToolCalls(response) toolCallList = agentToolCall[] # Helper: parse args (JSON string -> Dict, or pass through) - parse_args(raw) = raw isa AbstractDict ? Dict{String,Any}(raw) : - raw isa String ? JSON.parse(raw) : Dict{String,Any}() + parse_args(raw) = raw isa AbstractDict && !(raw isa Dict{String,Any}) ? + Dict{String,Any}(raw) : + raw isa String ? JSON.parse(raw) : + raw isa Dict{String,Any} ? raw : Dict{String,Any}() # Helper: build agentToolCall (positional) make_tc(tc_data, default_id=string(uuid4())) = begin @@ -713,12 +723,27 @@ end """ shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool +The `terminate` flag is set by tool implementations, not by the agent +or the LLM. It signals that the tool itself has completed the user's +request or encountered a fatal condition, so the agent should stop +processing further turns without calling the LLM again. + +Common scenarios where a tool sets `terminate: true`: + - **Task completion**: one-shot tools like `deploy`, `submit`, or + `send_payment` finish their work and report directly to the user + instead of asking the LLM "what next?" + - **Unrecoverable error**: a tool hits a fatal condition (database + connection lost, auth token expired) and stops the agent from + retrying endlessly. + - **Async handoff**: a tool triggers a long-running external operation + and wants the agent to stop now; the external system will resume + the agent later via `continue()`. + Returns `true` only when every finalized call in the batch has `result.terminate == true`. All tools must agree — if any tool did not request termination, the agent continues. This prevents -a single tool that happens to set `terminate: true` (e.g. for -metadata purposes) from accidentally stopping the agent when -other tools in the batch did not intend to terminate. +a single tool that happens to set `terminate: true` from accidentally +stopping the agent when other tools in the batch did not intend to terminate. # Arguments - `finalizedCalls`: Vector of finalized tool call outcomes @@ -739,7 +764,7 @@ true ``` """ function shouldTerminate(batches::Vector{finalizedOutcome})::Bool - return !isempty(batches) && all(b -> b.result.terminate, batches) + return !isempty(batches) && all(b -> b.result.terminate, batches) end """ @@ -849,7 +874,7 @@ function prepareToolCall( agentEventSink )::Union{preparedToolCall,immediateOutcome} agentEventSink("prepareToolCall 1") - tool = get(context.tools, toolCall.name, nothing) + tool = get(context.tools, toolCall.name, nothing) # pick a called tool from tool store if tool === nothing agentEventSink("prepareToolCall 2") return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) @@ -859,8 +884,10 @@ function prepareToolCall( agentEventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) + agentEventSink(string(prepared.arguments)) agentEventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) + agentEventSink(string(validatedArgs)) agentEventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing @@ -886,12 +913,12 @@ function prepareToolCall( return preparedToolCall(tool, toolCall, validatedArgs) catch e bt = catch_backtrace() - err_msg = sprint() do io + errMsg = sprint() do io showerror(io, e, bt) println(io) end - agentEventSink(err_msg) + agentEventSink(errMsg) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -937,36 +964,40 @@ executePreparedToolCall(prep, nothing, emit) # => executedOutcome(createErrorToolResult("Connection timeout"), true) ``` """ + function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, agentEventSink, )::executedOutcome agentEventSink("executePreparedToolCall 1") - updateEvents = promise[] - accepting = true + agentEventSink(prep.toolCall.id) + agentEventSink(prep.toolCall.name) + agentEventSink("executePreparedToolCall 2") + s = string(prep.args) + agentEventSink(s) + agentEventSink("executePreparedToolCall 3") + t = string(fieldnames(typeof(prep.tool))) + agentEventSink("executePreparedToolCall 3-1 " * t) try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, - agentEventSink(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - accepting = false - wait.(updateEvents) + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) + agentEventSink(result.content[1].text) + agentEventSink("executePreparedToolCall 4") return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + catch e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + agentEventSink(errMsg) + + return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end end + # ── per-call finalization ─────────────────────────────────────── """ @@ -1030,16 +1061,19 @@ function finalizeExecutedToolCall( executed::executedOutcome, config::agentLoopConfig, signal::Union{Nothing,abortSignal}, + agentEventSink )::finalizedOutcome - + agentEventSink("finalizeExecutedToolCall 1") result = executed.result isError = executed.isError - + agentEventSink("finalizeExecutedToolCall 2") if config.afterToolCall !== nothing try after = config.afterToolCall( - afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), + signal ) + agentEventSink("finalizeExecutedToolCall 3") if after !== nothing result = merge(result, dict(:content=>get(after,:content,result.content), :details=>get(after,:details,result.details), @@ -1047,12 +1081,19 @@ function finalizeExecutedToolCall( :terminate=>get(after,:terminate,result.terminate))) isError = get(after, :isError, isError) end - catch err - result = createErrorToolResult(sprint(showerror, err)) + catch e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + agentEventSink(errMsg) + + result = createErrorToolResult(sprint(showerror, e)) isError = true end end - + agentEventSink("finalizeExecutedToolCall 4") return finalizedOutcome(prep.toolCall, result, isError) end @@ -1123,30 +1164,32 @@ function executeToolCallsSequential( messages = toolResultMessage[] for tc in toolCalls - agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) - agentEventSink("executeToolCallsSequential 2") - if prep isa immediateOutcome - agentEventSink("executeToolCallsSequential 2-1") - finalized = finalizedOutcome(tc, prep.result, prep.isError) - agentEventSink("executeToolCallsSequential 2-2") - else - agentEventSink("executeToolCallsSequential 3") - executed = executePreparedToolCall(prep, signal, agentEventSink) - agentEventSink("executeToolCallsSequential 3-1") - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, - signal) - agentEventSink("executeToolCallsSequential 3-2") - end - agentEventSink("executeToolCallsSequential 4") - agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), - $(finalized.result), $(finalized.isError)") - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - agentEventSink("executeToolCallsSequential 5") - if signal !== nothing && signal.aborted - break - end + agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + agentEventSink("executeToolCallsSequential " * string(prep.args)) + + if prep isa immediateOutcome + agentEventSink("executeToolCallsSequential 2-1") + finalized = finalizedOutcome(tc, prep.result, prep.isError) + agentEventSink("executeToolCallsSequential 2-2") + else + agentEventSink("executeToolCallsSequential 3") + #XXX + executed = executePreparedToolCall(prep, signal, agentEventSink) + agentEventSink("executeToolCallsSequential 3-1") + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, + signal, agentEventSink) + agentEventSink("executeToolCallsSequential 3-2") + end + agentEventSink("executeToolCallsSequential 4") + agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), + $(finalized.result), $(finalized.isError)") + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + agentEventSink("executeToolCallsSequential 5") + if signal !== nothing && signal.aborted + break + end end agentEventSink("executeToolCallsSequential 6") return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) @@ -1218,12 +1261,12 @@ function executeToolCallsParallel( agentEventSink, )::agentToolCallBatch - entries = union{finalizedOutcome,task{finalizedOutcome}}[] + entries = Union{finalizedOutcome,Task}[] for tc in toolCalls agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) @@ -1231,15 +1274,15 @@ function executeToolCallsParallel( finalized.result, finalized.isError)) push!(entries, finalized) else - task = task() do + t = Task() do executed = executePreparedToolCall(prep, signal, agentEventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) return finalized end - schedule(task) - push!(entries, task) + schedule(t) + push!(entries, t) end if signal !== nothing && signal.aborted @@ -1249,7 +1292,7 @@ function executeToolCallsParallel( finalizedCalls = finalizedOutcome[] for entry in entries - outcome = entry isa task ? fetch(entry) : entry + outcome = entry isa Task ? fetch(entry) : entry push!(finalizedCalls, outcome) end diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index ef33baa..1d6bd52 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,6 +1,6 @@ module toolRegistry -export toolStore, loadTools, registerTool, getTools, clearTools, listTool +export toolStore, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures @@ -45,7 +45,7 @@ end Return an `agentTool` definition for listing registered tools. Each call produces a **new** tool object that captures (closes over) -`store`. `loadTools` auto-registers one so the LLM can discover tools +`store`. `register_all_tools` auto-registers one so the LLM can discover tools at runtime. # Arguments @@ -55,7 +55,7 @@ at runtime. ```julia julia> store = toolStore(name="agent1"); -julia> loadTools(store, "src/tools") # auto-registers listTools +julia> register_all_tools(store) # auto-registers listTools [toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) [toolRegistry:agent1] Registered tool: listTools @@ -97,99 +97,7 @@ function listTool(store::toolStore)::agentTool ) end -""" -Load `.jl` tool files from `dir` into `store`, then auto-register -`listTool` so the LLM can discover available tools at runtime. - -Each `.jl` file must define `function getTool()::agentTool ... end`. -Files are sorted alphabetically for deterministic registration order. -Each file is loaded into its own Julia submodule to avoid name collisions. - -# Arguments -- `store`: Tool store to populate -- `dir`: Directory containing `.jl` tool files - -# Returns -- The same `store.tools` dict (modified in place) - -# Errors -- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()` - -# Example -```julia -julia> store = toolStore(name="agent1"); - -julia> loadTools(store, "src/tools") -[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) -[toolRegistry:agent1] Loaded tool: getTime (Time Lookup) -[toolRegistry:agent1] Registered tool: listTools -OrderedDict{String, agentTool} with 3 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) - "listTools" => agentTool(...) -``` -""" -function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} - if !isdir(dir) - throw(ArgumentError("Tool directory does not exist: $dir")) - end - - jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) - sort!(jl_files) - - for filename in jl_files - filepath = joinpath(dir, filename) - - # Derive a unique module name from the filename only (not full path). - # e.g. "getWeather.jl" -> "_tool_getWeather" - mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => "")) - - # Build the complete module as a string and eval the parsed code. - # Julia does not allow `module ... end` inside eval(quote ...), - # and constructing the module AST by hand is fragile. - # Instead, we generate the full module source as a string, - # parse it, and eval the resulting expression. - # Each tool file declares its own dependencies via `using` statements - # at the top of the file — the registry only injects `using ..type` - # to make core types (agentTool, textContent, etc.) available. - file_content = read(filepath, String) - module_code = """ - module $(mod_name) - using ..type - $(file_content) - end - """ - mod = eval(Meta.parse(module_code)) - - # Call getTool() via Core.eval in the submodule's scope. - # This evaluates getTool() entirely within the new module's world, - # completely avoiding world-age issues — no invokelatest needed. - # Note: all uses of `tool` must be inside the `try` block because - # Julia 1.12's SSA form doesn't track `tool` as definitely assigned - # after a `try-catch` where it's only assigned inside `try`. - try - tool = Core.eval(mod, :(getTool())) - if !(tool isa agentTool) - throw(ArgumentError( - "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" - )) - end - store.tools[tool.name] = tool - println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))") - catch e - if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) - throw(ArgumentError( - "Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " * - "Each tool file must define: function getTool()::agentTool ... end" - )) - end - rethrow(e) - end - end - - registerTool(store, listTool(store)) - return store.tools -end +# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope """ registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 88098d7..748d5e5 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -1,3 +1,4 @@ +using .type using Dates """ @@ -15,7 +16,7 @@ Demonstrates custom validation beyond simple required-field checking: - `nothing` if validation passes - `String` error message if validation fails """ -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} tz = get(args, "timezone", nothing) city = get(args, "city", "") @@ -43,8 +44,8 @@ Execute the getTime tool. Returns mock time data for the given timezone or city. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult +function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + onPartialResult) tz = get(args, "timezone", nothing) city = get(args, "city", "") if tz !== nothing @@ -61,7 +62,7 @@ end """ Define and return the getTime agentTool. """ -function getTool()::agentTool +function getTimeTool()::agentTool return agentTool( name = "getTime", label = "Time Lookup", @@ -74,9 +75,9 @@ function getTool()::agentTool ), "required" => [] ), - execute = executeTool, + execute = getTimeExecute, prepareArguments = nothing, - validateRequiredArgs = validateRequiredArgs, + validateRequiredArgs = getTimeValidateRequiredArgs, parallelToolExecute = false ) end diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 61639cf..5cca9cd 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,24 +1,33 @@ +using msghandler +using .type + """ Execute the getWeather tool. Returns mock weather data for the given city and temperature units. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult - city = get(args, "city", "") - units = get(args, "units", "celsius") - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), nothing, false - ) +function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + agentEventSink) + + agentEventSink("Getting weather...") + + city = get(args, "city", "") + units = get(args, "units", "celsius") + temp = units == "fahrenheit" ? "72" : "22" + unit_symbol = units == "celsius" ? "°C" : "°F" + + return agentToolResult( + [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], + Dict{Any,Any}(), + nothing, + false + ) end """ Define and return the getWeather agentTool. """ -function getTool()::agentTool +function getWeatherTool()::agentTool return agentTool( name = "getWeather", label = "Weather Lookup", @@ -31,7 +40,7 @@ function getTool()::agentTool ), "required" => ["city"] ), - execute = executeTool, + execute = getWeatherExecute, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index c682e1d..8524772 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -1,3 +1,4 @@ +using .type using JSON """ @@ -7,15 +8,17 @@ The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks -up the new file. The new tool is immediately available. +After calling this tool, add the new file to `YiemAgent.jl` with an `include()` +statement (after `include("toolRegistry.jl")`), then restart the agent. +The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`. # Example 1. Agent calls writeTool with a spec for a "searchWine" tool 2. writeTool generates src/tools/searchWine.jl -3. Restart agent — loadTools() picks up the new file -4. Agent calls searchWine with args +3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl +4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() +5. Restart agent — new tool is available # How It Works @@ -24,7 +27,7 @@ tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string - Indents `executeCode` with 4 spaces - Wraps it inside `function executeTool(...)::agentToolResult ... end` - - Appends `getTool()` returning an `agentTool` struct + - Appends `writeToolTool()` returning an `agentTool` struct - Writes the combined string to `src/tools/.jl` # Important Notes @@ -105,7 +108,7 @@ end """ Define and return the writeTool agentTool. """ -function getTool()::agentTool +function writeToolTool()::agentTool return agentTool( name = "writeTool", label = "Create Tool", @@ -127,7 +130,7 @@ function getTool()::agentTool ), "required" => ["name", "label", "description", "inputSchema", "executeCode"] ), - execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin + execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin tool_name = get(args, "name", "")::String tool_label = get(args, "label", tool_name)::String tool_description = get(args, "description", "")::String @@ -250,13 +253,13 @@ function getTool()::agentTool tool_code = join(parts) - # Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools") + # Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools() write(filepath, tool_code) onPartialResult(Dict("status" => "Done")) return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")], + [textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")], Dict{Any,Any}( "file" => filepath, "name" => tool_name, diff --git a/src/type.jl b/src/type.jl index b6bf8a9..6eab694 100644 --- a/src/type.jl +++ b/src/type.jl @@ -264,7 +264,7 @@ struct agentTool # A tool available to the agent label::String # Human-readable tool name description::String # What the tool does inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) - execute::Function # Tool execution function + execute # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel @@ -274,7 +274,7 @@ end Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. """ function agentTool(; name::String, label::String, description::String, inputSchema::Any, - execute::Function, prepareArguments::Union{Function, Nothing}=nothing, + execute, prepareArguments::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing, parallelToolExecute::Bool=false) return agentTool(name, label, description, inputSchema, execute, diff --git a/src/utils.jl b/src/utils.jl index 18e5147..f2dc740 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -241,7 +241,38 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} return openaiReadyMsg end -#TODO +""" + 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 @@ -254,8 +285,31 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal return beforeToolCallResult(false, "N/A") end -#TODO -function afterToolCall(context::beforeToolCallContext, signal::abortSignal +""" + 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 diff --git a/test/toolTest.jl b/test/toolTest.jl index b239f36..f613397 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -1,35 +1,19 @@ using Test +using Dates using YiemAgent using YiemAgent.toolRegistry using YiemAgent.type +using YiemAgent.agentCore -# Path to the real tools directory -TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") - -@testset "loadTools with toolStore" begin +@testset "register_all_tools with toolStore" begin # ------------------------------------------------------------------ # - # 1. loadTools throws on non-existent directory # + # 1. register_all_tools registers all static tools + listTools # # ------------------------------------------------------------------ # store = toolStore(name="test1") - @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") - - # ------------------------------------------------------------------ # - # 2. loadTools throws if a .jl file does not define getTool() # - # Must run BEFORE any other loadTools call (getTool binding # - # persists in module scope after include()). # - # ------------------------------------------------------------------ # - bad_dir = mktempdir() - write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") - @test_throws ArgumentError loadTools(store, bad_dir) - - # ------------------------------------------------------------------ # - # 3. loadTools loads actual tool files from src/tools/ # - # ------------------------------------------------------------------ # - store2 = toolStore(name="test2") - loaded = loadTools(store2, TOOLS_DIR) + loaded = register_all_tools(store) @test !isempty(loaded) - @test length(loaded) == 4 # 3 files + auto-registered listTools + @test length(loaded) == 4 # getWeather + getTime + writeTool + listTools names = [k for k in keys(loaded)] @test "getTime" in names @@ -38,16 +22,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test "listTools" in names # ------------------------------------------------------------------ # - # 4. loadTools returns tools sorted alphabetically by filename # - # (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end # + # 2. register_all_tools returns tools in registration order # # ------------------------------------------------------------------ # - @test collect(keys(loaded))[1] == "getTime" - @test collect(keys(loaded))[2] == "getWeather" + @test collect(keys(loaded))[1] == "getWeather" + @test collect(keys(loaded))[2] == "getTime" @test collect(keys(loaded))[3] == "writeTool" @test collect(keys(loaded))[4] == "listTools" # ------------------------------------------------------------------ # - # 5. Verify loaded tool fields are correct # + # 3. Verify loaded tool fields are correct # # ------------------------------------------------------------------ # # getTime time_tool = loaded["getTime"] @@ -74,7 +57,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test "executeCode" in wt.inputSchema["required"] # ------------------------------------------------------------------ # - # 6. Tool execution returns valid results # + # 4. Tool execution returns valid results # # ------------------------------------------------------------------ # sig = nothing op = x -> x # no-op partial result callback @@ -98,16 +81,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # execute getWeather with units result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) - @test occursin("72°F", result_w2.content[1].text) + @test occursin("72\u00b0F", result_w2.content[1].text) # ------------------------------------------------------------------ # - # 7. getTools / registerTool / clearTools (per-store isolation) # + # 5. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # store3 = toolStore(name="test3") registry_tools = getTools(store3) @test isempty(registry_tools) - # listTool is not auto-registered anymore — each store starts empty # Register tools manually registerTool(store3, loaded["getTime"]) registerTool(store3, loaded["getWeather"]) @@ -143,7 +125,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test reg["manualTool"].parallelToolExecute == true # ------------------------------------------------------------------ # - # 8. getTools returns direct reference (mutations affect registry) # + # 6. getTools returns direct reference (mutations affect registry) # # ------------------------------------------------------------------ # copy1 = getTools(store3) copy2 = getTools(store3) @@ -152,7 +134,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test isempty(getTools(store3)) # mutation propagates # ------------------------------------------------------------------ # - # 9. Per-store isolation — two stores don't share tools # + # 7. Per-store isolation — two stores don't share tools # # ------------------------------------------------------------------ # storeA = toolStore(name="isolationA") storeB = toolStore(name="isolationB") @@ -175,10 +157,10 @@ end @testset "listTool" begin store = toolStore(name="test_list") - loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools + register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools - # loadTools auto-registers listTool - @test "listTools" in keys(loaded) + # register_all_tools auto-registers listTool + @test "listTools" in keys(store.tools) # listTool returns an agentTool, not a string or array list_t = listTool(store) @@ -199,7 +181,7 @@ end # Each listTool call creates an independent closure storeB = toolStore(name="test_listB") - registerTool(storeB, loaded["getWeather"]) + registerTool(storeB, store.tools["getWeather"]) list_tB = listTool(storeB) resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) @@ -210,3 +192,176 @@ end @test occursin("getTime", resultA.content[1].text) @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather end + +@testset "executePreparedToolCall with static tools" begin + # Tests executePreparedToolCall with statically loaded tools. + # The world-age issue is resolved because tool.execute comes from + # a statically included module, not a dynamically created one. + + store = toolStore(name="test_static") + register_all_tools(store) + + weather_tool = store.tools["getWeather"] + + # Create a preparedToolCall that mimics what prepareToolCall() returns + tool_call = agentToolCall( + "function", "call-static-1", "getWeather", + Dict{String,Any}("city" => "San Francisco") + ) + prep = preparedToolCall( + weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco") + ) + sig = abortSignal(false) + + # This call goes through: executePreparedToolCall -> prep.tool.execute(...) + result = executePreparedToolCall( + prep, sig, x -> nothing + ) + + @test result isa executedOutcome + @test result.isError == false + @test result.result.content[1] isa textContent + @test occursin("San Francisco", result.result.content[1].text) +end + +@testset "executePreparedToolCall with validation (static tools)" begin + # Tests executePreparedToolCall with a tool that has custom validation hooks. + # This exercises the full tool execution path including validation. + + store = toolStore(name="test_static_validate") + register_all_tools(store) + + time_tool = store.tools["getTime"] + + tool_call = agentToolCall( + "function", "call-static-2", "getTime", + Dict{String,Any}("timezone" => "America/New_York") + ) + prep = preparedToolCall( + time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York") + ) + sig = abortSignal(false) + + result = executePreparedToolCall( + prep, sig, x -> nothing + ) + + @test result isa executedOutcome + @test result.isError == false + @test result.result.content[1] isa textContent + @test occursin("America/New_York", result.result.content[1].text) +end + +@testset "executeToolCallsSequential with static tools (full pipeline)" begin + # Tests the full tool execution pipeline: executeToolCallsSequential + # which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall + # with statically loaded tools. + + store = toolStore(name="test_full_pipeline") + register_all_tools(store) + + # Build agentContext from the store's tools + tools = getTools(store) + ctx = agentContext( + "test system prompt", + agentMessage[], + tools + ) + + # Create an assistant message containing tool calls + assistant_msg = assistantMessage( + role="assistant", + content=Vector{messageContent}(), + api="openai", + provider="test", + model="test-model", + usage=llmUsage(0, 0), + stopReason="tool_calls", + errorMessage=nothing, + timestamp=now() + ) + + # Create tool calls for multiple statically loaded tools + tool_calls = [ + agentToolCall( + "function", "call-seq-1", "getWeather", + Dict{String,Any}("city" => "Tokyo") + ), + agentToolCall( + "function", "call-seq-2", "getTime", + Dict{String,Any}("timezone" => "Europe/London") + ), + ] + + config = agentLoopConfig( + nothing, nothing, "sequential" + ) + sig = abortSignal(false) + + # Execute the full pipeline + batch = executeToolCallsSequential( + ctx, assistant_msg, tool_calls, config, sig, x -> nothing + ) + + @test batch.messages isa Vector{toolResultMessage} + @test length(batch.messages) == 2 + @test batch.messages[1].toolName == "getWeather" + @test batch.messages[1].isError == false + @test occursin("Tokyo", batch.messages[1].content[1].text) + @test batch.messages[2].toolName == "getTime" + @test batch.messages[2].isError == false + @test occursin("Europe/London", batch.messages[2].content[1].text) +end + +@testset "executeToolCallsParallel with static tools (full pipeline)" begin + # Same as above but tests parallel execution path. + + store = toolStore(name="test_parallel") + register_all_tools(store) + + tools = getTools(store) + ctx = agentContext( + "test system prompt", + agentMessage[], + tools + ) + + assistant_msg = assistantMessage( + role="assistant", + content=Vector{messageContent}(), + api="openai", + provider="test", + model="test-model", + usage=llmUsage(0, 0), + stopReason="tool_calls", + errorMessage=nothing, + timestamp=now() + ) + + tool_calls = [ + agentToolCall( + "function", "call-par-1", "getWeather", + Dict{String,Any}("city" => "Paris") + ), + agentToolCall( + "function", "call-par-2", "getTime", + Dict{String,Any}("city" => "Sydney") + ), + ] + + config = agentLoopConfig( + nothing, nothing, "parallel" + ) + sig = abortSignal(false) + + batch = executeToolCallsParallel( + ctx, assistant_msg, tool_calls, config, sig, x -> nothing + ) + + @test batch.messages isa Vector{toolResultMessage} + @test length(batch.messages) == 2 + @test batch.messages[1].toolName == "getWeather" + @test batch.messages[1].isError == false + @test batch.messages[2].toolName == "getTime" + @test batch.messages[2].isError == false +end diff --git a/user_code.jl b/user_code.jl new file mode 100644 index 0000000..2123d8a --- /dev/null +++ b/user_code.jl @@ -0,0 +1,113 @@ +using Revise, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, + NATS, Base.Threads +using YiemAgent, GeneralUtils, msghandler + + +""" Debug + +using JSON, NATS, msghandler +using NATS + +conn = NATS.connect("nats.yiem.cc") + +sub = NATS.subscribe(conn, "sommanion.debug") do msg + payload = NATS.payload(msg) + @info "debug" payload + + open("./log/error.log", "a") do io + println(io, payload) + end +end + +NATS.publish(conn, "sommanion.debug", "order-123") + + +# ---------------------------- inject this code into codebase to debug --------------------------- # +try + batch = someFunction(x, y, z) +catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + agentEventSink(err_msg) +end + +""" + +struct text2textInstructLLM + natsConn::NATS.Connection + topic::String + senderID::String + fileserver_url::String +end + +function (t::text2textInstructLLM)(openai_msg::Dict{String, Any}) + + payloads = [("msg", openai_msg, "dictionary")] # List of tuples + _, msg_envelope_json_str = msghandler.smartpack( + t.topic, + payloads; + sender_id=t.senderID, + msg_purpose="text2text", + fileserver_url=t.fileserver_url) + + reply = NATS.request(t.natsConn, t.topic, msg_envelope_json_str, timeout=180) + + incoming_env_json_str = String(reply.payload) + incoming_env = msghandler.smartunpack(incoming_env_json_str) + _llm_response = incoming_env["payloads"][1][2] + llm_response = _llm_response["choices"][1] + + return llm_response +end + + + +struct agentEventSink + natsConn::NATS.Connection + topic::String + senderID::String +end + +function (aes::agentEventSink)(msg::String) + NATS.publish(aes.natsConn, aes.topic, msg) +end + + + +config = JSON.parsefile("./appconfig.json") +agent_conn = NATS.connect(config["nats_server_info"]["url"]) + + +#WORKING load tools +text2text_llm = text2textInstructLLM(agent_conn, + config["externalservice"]["servicesloadbalancer"]["nats"], + "sender", + config["externalservice"]["fileserver"]["url"]) + +debugNats = agentEventSink(agent_conn, "sommanion.debug", "sender") + +agent = YiemAgent.yiemAgent( + text2text_llm; + agentEventSink=debugNats +) + +msg = Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "What's the weather in Bangkok?"), + # Dict( + # "type" => "image_url", + # "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") + # ), + ] + ) + +push!(agent.inputChannel, msg) + + + + -- 2.52.0 From fd616409dddd4a61eba90557eb860d8455d99a7f Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 15 Aug 2026 20:03:01 +0700 Subject: [PATCH 087/112] update --- etc.jl | 84 ++++--------------------------- src/agentCore.jl | 127 ++++++++++++++++++++--------------------------- src/type.jl | 2 - 3 files changed, 62 insertions(+), 151 deletions(-) diff --git a/etc.jl b/etc.jl index 7d92ff2..c6c563a 100644 --- a/etc.jl +++ b/etc.jl @@ -1,77 +1,11 @@ -i am not sure that's the case. see my NATS message log: - -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "_process_message 3" -┌ Info: debug -└ payload = "_process_message 5" -┌ Info: debug -└ payload = "_process_message 6" -┌ Info: debug -└ payload = "_process_message 7" - - -my NATS receiver report the following for a long time -┌ Info: debug -└ payload = "new user msg" - -untill I Ctrl + d so shutdown the process then i got the following report -┌ Info: debug -└ payload = "_process_message 3" -┌ Info: debug -└ payload = "_process_message 5" -┌ Info: debug -└ payload = "_process_message 6" -┌ Info: debug -└ payload = "_process_message 7" - - - - - - - - - -my point is if _process_message() actually run then this code in _process_message() -"raw_msg = take!(agent.inputChannel)" -should take the new msg message out of agent.inputChannel and there should be only one debug message showing -┌ Info: debug -└ payload = "new user msg" - -before reaching error("debug marker") - - - - - - - - - - - - - - - - - - - - +_processMessage 10 +JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in Bangkok is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user is asking for the weather in Bangkok.\nI have already retrieved the weather information in the previous turn and provided it to the user.\nThe user's current input is \"What's the weather in Bangkok?\", which is the same question as before.\nI should provide the same answer again.\nNo new tool calls are needed.\nI will simply state the weather information retrieved previously.\nWeather in Bangkok: Sunny, 22°C.\nI will output the answer directly.\n")) +_processMessage 11 +hasToolCalls: false +toolCallList: YiemAgent.type.agentToolCall[] +_processMessage 17 +_processMessage 18 +--- +here is my log. from the log, it seems like _processMessage() is finished but somehow the log didn't show _agentLoop 5 message. _processMessage() may not exit properly but why? diff --git a/src/agentCore.jl b/src/agentCore.jl index c45945e..c1a4168 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -169,62 +169,77 @@ julia> # Called automatically by yiemAgent constructor function _agentLoop(agent::yiemAgent) processMessageInputCh = Channel(32) try + newUserMsg = nothing processingTask = nothing + result = nothing """ cases: 1) agent -> idle, user msg -> nothing typeof(processingTask) == Nothing - agent._state.activeRun -> false agent.inputChannel -> nothing agent.followUpChannel -> nothing 2) agent -> idle, user msg -> new msg typeof(processingTask) == Nothing - agent._state.activeRun -> false agent.inputChannel -> new msg agent.followUpChannel -> nothing 3) agent -> running, user msg -> nothing typeof(processingTask) == Task, istaskdone(processingTask) -> false - agent._state.activeRun -> true agent.inputChannel -> nothing agent.followUpChannel -> nothing 4) agent -> running, user msg -> new msg typeof(processingTask) == Task, istaskdone(processingTask) -> false - agent._state.activeRun -> true agent.inputChannel -> new msg agent.followUpChannel -> nothing 5) agent -> running, user msg -> nothing, user msg follow up -> new msg typeof(processingTask) == Task, istaskdone(processingTask) -> false - agent._state.activeRun -> true agent.inputChannel -> nothing agent.followUpChannel -> new msg 6) agent -> idle, user msg -> nothing typeof(processingTask) == Task, istaskdone(processingTask) -> true - agent._state.activeRun -> false agent.inputChannel -> nothing agent.followUpChannel -> nothing """ while true - result = nothing - msg = nothing - while msg === nothing + while newUserMsg === nothing if isready(agent.inputChannel) - - # message will be taken in _processMessage() - msg = take!(agent.inputChannel) + agent.agentEventSink("_agentLoop 1") + # agent process new user msg immediately after the current tool call finished. + newUserMsg = take!(agent.inputChannel) agent.agentEventSink("new user msg") else + # check followUp message after _processMessage() is done + if typeof(processingTask) == Task && istaskdone(processingTask) == true + agent.agentEventSink("_agentLoop 2") + # if agent runs is done but followUpChannel has messages, + # put new message in inputChannel instead + if isready(agent.followUpChannel) + agent.agentEventSink("_agentLoop 3") + while isready(agent.followUpChannel) + followUpMsg = take!(agent.followUpChannel) + put!(agent.inputChannel, followUpMsg) + end + else # _processMessage() done and no followUp message. + agent.agentEventSink("_agentLoop 4") + result = fetch(processingTask) + put!(agent.outputChannel, result) + agent.agentEventSink(result.content[1].text) + processingTask = nothing # reset + newUserMsg = nothing # reset + result = nothing # reset + end + end yield() end end # Check for shutdown signal - if msg === :shutdown + if newUserMsg === :shutdown # Drain all remaining messages in the input channel if isready(agent.inputChannel) while isready(agent.inputChannel) @@ -238,67 +253,31 @@ function _agentLoop(agent::yiemAgent) end #TODO make sure every running tools ended properly + + newUserMsg = nothing # reset break else - agent.agentEventSink("_agentLoop push 1") - put!(processMessageInputCh, msg) - agent.agentEventSink("_agentLoop push 2") - end - - # start _processMessage loop - if agent._state.activeRun == false - agent.agentEventSink("_agentLoop 2") - # Dispatch message through the processing pipeline - processingTask = @spawn _processMessage( - processMessageInputCh, - agent.agentEventSink, - agent._state.messages, - agent._state.systemPrompt, - agent._state.tools, - agent.prepareContext, - agent.formatMsgForLLM, - agent.llmCall, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute, - ) - agent._state.activeRun = true - agent.agentEventSink("_agentLoop 3") - end - - # during agent runs, check followUp message after _processMessage() is done - if typeof(processingTask) == Task && istaskdone(processingTask) == false - agent.agentEventSink("_agentLoop 4") - # if followUp message available, add them all to agent.inputChannel - if isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 4-1") - while isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 4-2") - followMsg = take!(agent.followUpChannel) - put!(agent.inputChannel, followMsg) - end + # spawn new _processMessage() if it is not already running. + if processingTask === nothing + agent.agentEventSink("_agentLoop 2") + # Dispatch message through the processing pipeline + processingTask = @spawn _processMessage( + processMessageInputCh, + agent.agentEventSink, + agent._state.messages, + agent._state.systemPrompt, + agent._state.tools, + agent.prepareContext, + agent.formatMsgForLLM, + agent.llmCall, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute, + ) end - agent.agentEventSink("_agentLoop 4-3") - continue # continue to process user message in the next loop - - elseif typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.agentEventSink("_agentLoop 5") - # if agent runs is done but followUpChannel has messages, discard all message in it. - # when agent work is done it should not accept follow up msg. - # user should put new message in inputChannel instead - if isready(agent.followUpChannel) - while isready(agent.followUpChannel) - _ = take!(agent.followUpChannel) - end - end - result = fetch(processingTask) - put!(agent.outputChannel, result) - agent._state.activeRun = false # reset - processingTask = nothing # reset + put!(processMessageInputCh, newUserMsg) + newUserMsg = nothing # reset end - agent.agentEventSink("_agentLoop 6") - agent.agentEventSink(string(typeof(processingTask))) - agent.agentEventSink("_agentLoop 7") end catch e # On any error, send error response and exit the loop @@ -368,17 +347,17 @@ function _processMessage( # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) agentEventSink("_processMessage 2") - raw_msg = take!(inputChannel) + newUserMsg_openai = take!(inputChannel) agentEventSink("_processMessage 3") - if raw_msg === :shutdown + if newUserMsg_openai === :shutdown agentEventSink("_processMessage 4") # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end agentEventSink("_processMessage 5") - user_msg = OpenAiToUserMessage(raw_msg) - push!(agentMsgHistory, user_msg) + newUserMsg = OpenAiToUserMessage(newUserMsg_openai) + push!(agentMsgHistory, newUserMsg) agentEventSink("_processMessage 6") end agentEventSink("_processMessage 7") diff --git a/src/type.jl b/src/type.jl index 6eab694..54ff272 100644 --- a/src/type.jl +++ b/src/type.jl @@ -317,7 +317,6 @@ mutable struct agentState # Mutable runtime state of an agen messages::Vector{agentMessage} pendingToolCalls::Vector{String} # Tool call IDs waiting for results - activeRun::Bool # is agent processing user message? errorMessage::Union{String, Nothing} # Last error message end @@ -354,7 +353,6 @@ function agentState( deepcopy(tools), deepcopy(messages), Vector{String}(), - false, nothing, ) end -- 2.52.0 From a29a82b74c6f49a20ddef1233782ee490308a9c8 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 13:36:01 +0700 Subject: [PATCH 088/112] update --- etc.jl | 16 ++-- etc.md | 8 ++ src/agentCore.jl | 197 +++++++++++++++++++++++++---------------------- src/type.jl | 58 ++++++++++++-- src/utils.jl | 40 ++++++++++ 5 files changed, 212 insertions(+), 107 deletions(-) create mode 100644 etc.md diff --git a/etc.jl b/etc.jl index c6c563a..be4b0e2 100644 --- a/etc.jl +++ b/etc.jl @@ -1,11 +1,5 @@ -_processMessage 10 -JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in Bangkok is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user is asking for the weather in Bangkok.\nI have already retrieved the weather information in the previous turn and provided it to the user.\nThe user's current input is \"What's the weather in Bangkok?\", which is the same question as before.\nI should provide the same answer again.\nNo new tool calls are needed.\nI will simply state the weather information retrieved previously.\nWeather in Bangkok: Sunny, 22°C.\nI will output the answer directly.\n")) -_processMessage 11 -hasToolCalls: false -toolCallList: YiemAgent.type.agentToolCall[] -_processMessage 17 -_processMessage 18 - ---- -here is my log. from the log, it seems like _processMessage() is finished but somehow the log didn't show _agentLoop 5 message. _processMessage() may not exit properly but why? - +check my understand: +1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and +it will be the latest message in agent._state.messages. then _agentLoop() can pick it as +the output to outputChannel +2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall \ No newline at end of file diff --git a/etc.md b/etc.md new file mode 100644 index 0000000..4664add --- /dev/null +++ b/etc.md @@ -0,0 +1,8 @@ +check my understanding +1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and it will be the latest message in agent._state.messages. then _agentLoop() can pick it as the output to outputChannel +2) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is false then _processMessage() loop continue +3) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is true then final_response message get pushed into agent._state.messages. _processMessage() loop exit. then _agentLoop() can pick it as the output to outputChannel + +Is my understanding correct? + + diff --git a/src/agentCore.jl b/src/agentCore.jl index c1a4168..5284d54 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -208,25 +208,25 @@ function _agentLoop(agent::yiemAgent) while true while newUserMsg === nothing if isready(agent.inputChannel) - agent.agentEventSink("_agentLoop 1") + agent.agentEventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))") # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) agent.agentEventSink("new user msg") else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.agentEventSink("_agentLoop 2") + agent.agentEventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))") # if agent runs is done but followUpChannel has messages, # put new message in inputChannel instead if isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 3") + agent.agentEventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))") while isready(agent.followUpChannel) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) end else # _processMessage() done and no followUp message. - agent.agentEventSink("_agentLoop 4") - result = fetch(processingTask) + agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") + result = agent._state.messages[end] put!(agent.outputChannel, result) agent.agentEventSink(result.content[1].text) processingTask = nothing # reset @@ -259,7 +259,7 @@ function _agentLoop(agent::yiemAgent) else # spawn new _processMessage() if it is not already running. if processingTask === nothing - agent.agentEventSink("_agentLoop 2") + agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") # Dispatch message through the processing pipeline processingTask = @spawn _processMessage( processMessageInputCh, @@ -325,8 +325,8 @@ function _processMessage( beforeToolCall::Union{Function, Nothing}, afterToolCall::Union{Function, Nothing}, parallelToolExecute::Bool, -)::assistantMessage - agentEventSink("_processMessage 1") +)::Nothing + agentEventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") # loop until llmCall() response didn't use tool calls final_response = nothing @@ -346,41 +346,46 @@ function _processMessage( while true # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) - agentEventSink("_processMessage 2") + agentEventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") newUserMsg_openai = take!(inputChannel) - agentEventSink("_processMessage 3") + agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") if newUserMsg_openai === :shutdown - agentEventSink("_processMessage 4") + agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end - agentEventSink("_processMessage 5") + agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") newUserMsg = OpenAiToUserMessage(newUserMsg_openai) push!(agentMsgHistory, newUserMsg) - agentEventSink("_processMessage 6") + agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") end - agentEventSink("_processMessage 7") + agentEventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") # call prepareContext() state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - agentEventSink("_processMessage 8") + agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") preparedContext = prepareContext(state, agentEventSink) - agentEventSink("_processMessage 8") + agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) - - agentEventSink("_processMessage 10") + + agentEventSink("_processMessage 10 formattedMessages $formattedMessages") """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ response = llmCall(formattedMessages) - agentEventSink(string(response)) + agentEventSink(" llmCall " * string(response)) - agentEventSink("_processMessage 11") + agentEventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") + agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") + agentEventSink(string(assistant_msg)) + agentEventSink("_processMessage 11-1") + + # Add assistant message (tool calls or text) to history for next LLM turn + push!(agentMsgHistory, assistant_msg) if hasToolCalls && length(toolCallList) > 0 # Build context and config for executeToolCalls @@ -392,20 +397,20 @@ function _processMessage( ) signal = abortSignal(false) - agentEventSink("_processMessage 12") + agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") # call executeToolCalls() toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, agentEventSink) - agentEventSink("_processMessage 13") + agentEventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") # save toolResults to messages for toolResult in toolResultBatch.messages push!(agentMsgHistory, toolResult) end - agentEventSink("_processMessage 14") + agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") if toolResultBatch.terminate - agentEventSink("_processMessage 15") + agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] for toolResult in toolResultBatch.messages @@ -419,7 +424,7 @@ function _processMessage( end end end - agentEventSink("_processMessage 16") + agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") final_response = assistantMessage( role="assistant", content=final_content, @@ -434,17 +439,17 @@ function _processMessage( end, timestamp=now(), ) + push!(agentMsgHistory, final_response) break end else - agentEventSink("_processMessage 17") - # LLM did not use tool calls — this is the final response - final_response = assistant_msg + agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") + # LLM did not use tool calls — break end end - agentEventSink("_processMessage 18") - return final_response + agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") + return nothing end @@ -516,24 +521,28 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage end """ - _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, assistantMessage} + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, Union{assistantMessageToolCall, assistantMessage}} -Extracts tool calls from the LLM response and constructs an `assistantMessage`. +Extracts tool calls from the LLM response and constructs a message object. Supports two response formats: 1. **Message format** (e.g. from LMStudio.jl / vLLM): - `response["message"]["tool_calls"]` — array of tool call objects with - `"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`, - and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`. + `response["message"]["tool_calls"]` — array of tool call objects with + `"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`, + and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`. 2. **Content blocks format** (e.g. from OpenAI API): - `response.content` — array of content blocks. Blocks with `"type" => "tool_calls"` - contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` - have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). + `response.content` — array of content blocks. Blocks with `"type" => "tool_calls"` + contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` + have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). -The `assistantMessage` is constructed from: -- `reasoning_content` (string) → added as a `textContent` block -- `response.content` blocks (text/ reasoning) → added to content +When `hasToolCalls` is true, returns an `assistantMessageToolCall` with the tool calls +and reasoning content. When `hasToolCalls` is false, returns an `assistantMessage` +with text/content blocks from the response. + +The message is constructed from: +- `reasoning_content` (string) → stored in `reasoning` field (for tool calls) or `textContent` (for text) +- `response.content` blocks (text/reasoning) → added to content for text responses - Top-level `api`, `provider`, `model`, `usage` → copied to the message - `finish_reason` → used as `stopReason` @@ -541,7 +550,9 @@ The `assistantMessage` is constructed from: - `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) # Returns -- `Tuple{Bool, Vector{agentToolCall}, assistantMessage}`: `(hasToolCalls, toolCallList, assistantMsg)` +- `Tuple{Bool, Vector{agentToolCall}, Union{assistantMessageToolCall, assistantMessage}}`: + `(hasToolCalls, toolCallList, message)` where message is `assistantMessageToolCall` + when tool calls exist, `assistantMessage` otherwise # Example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ @@ -616,20 +627,6 @@ function _extractToolCalls(response) end # ── Construct assistantMessage from response ────────────────────── - # Check Format 1 nested message for reasoning_content, role, etc. - reasoning = get(response, "reasoning_content", nothing) - if reasoning === nothing && msg !== nothing && msg isa AbstractDict - reasoning = get(msg, "reasoning_content", nothing) - end - if reasoning isa String - reasoning_text = reasoning - elseif reasoning isa textContent - reasoning_text = reasoning.text - else - reasoning_text = "" - end - reasoning_block = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] - finish_reason = get(response, "finish_reason", nothing) stop_reason = finish_reason isa AbstractString ? String(finish_reason) : "end_turn" @@ -638,6 +635,21 @@ function _extractToolCalls(response) model = get(response, "model", nothing) usage = get(response, "usage", nothing) + # Collect reasoning from reasoning_content field (Format 1: Anthropic-style) + reasoning_text = get(response, "reasoning_content", nothing) + if reasoning_text === nothing && msg !== nothing && msg isa AbstractDict + reasoning_text = get(msg, "reasoning_content", nothing) + end + if reasoning_text isa String + reasoning_text = reasoning_text + elseif reasoning_text isa textContent + reasoning_text = reasoning_text.text + else + reasoning_text = "" + end + reasoning_content = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] + + # Collect content blocks from response.content array (Format 2: OpenAI-style) content_from_response = get(response, "content", nothing) content_blocks = Vector{messageContent}() if content_from_response isa Vector @@ -658,15 +670,11 @@ function _extractToolCalls(response) end end - # Combine content blocks and reasoning - if !isempty(content_blocks) && !isempty(reasoning_block) - all_content = vcat(reasoning_block, content_blocks) - elseif !isempty(content_blocks) - all_content = content_blocks - elseif !isempty(reasoning_block) - all_content = reasoning_block + # Combine reasoning_content field + content array blocks, deduplicating reasoning + if !isempty(reasoning_content) + all_content = vcat(reasoning_content, content_blocks) else - all_content = textContent[] + all_content = content_blocks end error_msg = get(response, "error_message", get(response, "errorMessage", nothing)) @@ -684,17 +692,32 @@ function _extractToolCalls(response) end end - assistant_msg = assistantMessage( - role = role, - content = all_content, - api = api isa AbstractString ? String(api) : "", - provider = provider isa AbstractString ? String(provider) : "", - model = model, - usage = usage, - stopReason = stop_reason, - errorMessage = error_msg, - timestamp = now(), - ) + if hasToolCalls + assistant_msg = assistantMessageToolCall( + role = role, + toolCalls = toolCallList, + content = all_content, + api = api isa AbstractString ? String(api) : "", + provider = provider isa AbstractString ? String(provider) : "", + model = model, + usage = usage, + stopReason = stop_reason, + errorMessage = error_msg, + timestamp = now(), + ) + else + assistant_msg = assistantMessage( + role = role, + content = all_content, + api = api isa AbstractString ? String(api) : "", + provider = provider isa AbstractString ? String(provider) : "", + model = model, + usage = usage, + stopReason = stop_reason, + errorMessage = error_msg, + timestamp = now(), + ) + end return hasToolCalls, toolCallList, assistant_msg end @@ -846,7 +869,7 @@ prepareToolCall(context, msg, tc, config, abortedSignal) """ function prepareToolCall( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCall::agentToolCall, config::agentLoopConfig, signal::abortSignal, @@ -863,10 +886,10 @@ function prepareToolCall( agentEventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) - agentEventSink(string(prepared.arguments)) + agentEventSink("prepared " * string(prepared.arguments)) agentEventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) - agentEventSink(string(validatedArgs)) + agentEventSink("validatedArgs " * string(validatedArgs)) agentEventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing @@ -950,14 +973,8 @@ function executePreparedToolCall( agentEventSink, )::executedOutcome agentEventSink("executePreparedToolCall 1") - agentEventSink(prep.toolCall.id) - agentEventSink(prep.toolCall.name) agentEventSink("executePreparedToolCall 2") - s = string(prep.args) - agentEventSink(s) agentEventSink("executePreparedToolCall 3") - t = string(fieldnames(typeof(prep.tool))) - agentEventSink("executePreparedToolCall 3-1 " * t) try result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) @@ -1035,7 +1052,7 @@ finalizeExecutedToolCall(context, msg, prep, execFail, config, nothing) """ function finalizeExecutedToolCall( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, prep::preparedToolCall, executed::executedOutcome, config::agentLoopConfig, @@ -1132,7 +1149,7 @@ executeToolCallsSequential(ctx, msg, [deployTc], config, nothing, emit) """ function executeToolCallsSequential( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, @@ -1161,7 +1178,7 @@ function executeToolCallsSequential( agentEventSink("executeToolCallsSequential 3-2") end agentEventSink("executeToolCallsSequential 4") - agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), + agentEventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), $(finalized.result), $(finalized.isError)") push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) @@ -1233,7 +1250,7 @@ executeToolCallsParallel(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit) """ function executeToolCallsParallel( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, @@ -1335,7 +1352,7 @@ executeToolCalls(ctx, msg, [searchTc, fetchTc], configSequential, nothing, emit) """ function executeToolCalls( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, diff --git a/src/type.jl b/src/type.jl index 54ff272..d7eee74 100644 --- a/src/type.jl +++ b/src/type.jl @@ -6,8 +6,8 @@ modelCost, llmModel, llmUsage, # Message content types textContent, imageContent, - # Message types - userMessage, assistantMessage, toolResultMessage, + # Message types + userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage, # Tool types agentTool, validateRequiredArgs, # Context types @@ -108,6 +108,52 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp= return userMessage(role, content, timestamp) end +struct assistantMessageToolCall <: agentMessage # Assistant message containing tool calls + role::String # Always "assistant" + toolCalls::Vector{agentToolCall} # Tool calls to execute + content::Vector{messageContent} # Reasoning/thinking content blocks + api::String # API name used (e.g., "openai") + provider::String # Provider name (e.g., "anthropic") + model::String # Model identifier + usage::llmUsage # Token usage for this message + stopReason::String # Why generation stopped (e.g., "tool_calls") + errorMessage::Union{String, Nothing} # Error if generation failed + timestamp::Timestamp # When the message was received +end + +""" +Create a new assistant message containing tool calls. + +# Arguments +- `role::String`: Always "assistant" +- `toolCalls::Vector{agentToolCall}`: Tool calls to execute +- `content::Vector{messageContent}`: Reasoning/thinking content blocks +- `api::String`: API name used +- `provider::String`: Provider name +- `model::String`: Model identifier +- `usage::llmUsage`: Token usage +- `stopReason::String`: Why generation stopped +- `errorMessage::Union{String, Nothing}`: Error if generation failed +- `timestamp::Timestamp`: When the message was received + +# Returns +- A new `assistantMessageToolCall` instance + +# Examples +```julia +julia> tc = agentToolCall("function", "call_1", "getWeather", Dict("city" => "Tokyo")) +julia> msg = assistantMessageToolCall(toolCalls=[tc], stopReason="tool_calls") +assistantMessageToolCall("assistant", [agentToolCall(...)], messageContent[], "", "", "", llmUsage(0, 0), "tool_calls", nothing, DateTime(...)) +``` +""" +function assistantMessageToolCall(; role="assistant", toolCalls=agentToolCall[], + content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0), + stopReason="tool_calls", errorMessage=nothing, timestamp=now()) + model_str = model isa AbstractString ? String(model) : "" + return assistantMessageToolCall(role, toolCalls, content, api, provider, model_str, + usage, stopReason, errorMessage, timestamp) +end + struct assistantMessage <: agentMessage # Message from the AI assistant role::String # Always "assistant" content::Vector{messageContent} # Text and/or image content @@ -434,13 +480,13 @@ end Context passed to the `beforeToolCall` hook. # Arguments -- `message::assistantMessage`: The assistant message containing the tool call +- `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call being prepared - `args::Dict{String,Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ struct beforeToolCallContext - message::assistantMessage + message::assistantMessageToolCall toolCall::agentToolCall args::Dict{String,Any} context::agentContext @@ -455,7 +501,7 @@ end Context passed to the `afterToolCall` hook. # Arguments -- `message::assistantMessage`: The assistant message containing the tool call +- `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call that was executed - `args::Dict{String,Any}`: Tool arguments - `result::agentToolResult`: The raw tool result @@ -463,7 +509,7 @@ Context passed to the `afterToolCall` hook. - `context::agentContext`: Current conversation context """ struct afterToolCallContext - message::assistantMessage + message::assistantMessageToolCall toolCall::agentToolCall args::Dict{String,Any} result::agentToolResult diff --git a/src/utils.jl b/src/utils.jl index f2dc740..29ac6a2 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -221,6 +221,8 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} 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 @@ -336,6 +338,44 @@ 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} -- 2.52.0 From 25f84686964178230d69005bae497223b915b5d5 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 17:24:29 +0700 Subject: [PATCH 089/112] text message process works --- src/agentCore.jl | 29 +++++++++++++++++++++++------ src/type.jl | 23 +++++++++++++---------- test/_extractToolCalls.jl | 6 +++++- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 5284d54..09c4297 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -227,8 +227,11 @@ function _agentLoop(agent::yiemAgent) else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") result = agent._state.messages[end] + filtered_content = [c for c in result.content if !(c isa reasoningContent)] put!(agent.outputChannel, result) - agent.agentEventSink(result.content[1].text) + if !isempty(filtered_content) && filtered_content[1] isa textContent + agent.agentEventSink(filtered_content[1].text) + end processingTask = nothing # reset newUserMsg = nothing # reset result = nothing # reset @@ -381,7 +384,7 @@ function _processMessage( # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") - agentEventSink(string(assistant_msg)) + agentEventSink("assistant_msg " * string(assistant_msg)) agentEventSink("_processMessage 11-1") # Add assistant message (tool calls or text) to history for next LLM turn @@ -406,6 +409,7 @@ function _processMessage( # save toolResults to messages for toolResult in toolResultBatch.messages + agentEventSink("toolResult " * string(toolResult)) push!(agentMsgHistory, toolResult) end agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") @@ -554,7 +558,9 @@ The message is constructed from: `(hasToolCalls, toolCallList, message)` where message is `assistantMessageToolCall` when tool calls exist, `assistantMessage` otherwise -# Example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) +# Example + 1) llm_useTool = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) + 2) llm_notUseTool = JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in London, UK is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user asked for the weather in London.\nI called the `getWeather` tool for London, UK.\nThe response indicates it's Sunny and 22°C.\nI will convey this information to the user.\n")) """ function _extractToolCalls(response) hasToolCalls = false @@ -642,14 +648,15 @@ function _extractToolCalls(response) end if reasoning_text isa String reasoning_text = reasoning_text - elseif reasoning_text isa textContent + elseif reasoning_text isa reasoningContent reasoning_text = reasoning_text.text else reasoning_text = "" end - reasoning_content = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] + reasoning_content = !isempty(reasoning_text) ? [reasoningContent(reasoning_text)] : reasoningContent[] # Collect content blocks from response.content array (Format 2: OpenAI-style) + # or as a plain string (Format 1: LMStudio.jl non-tool-calls response) content_from_response = get(response, "content", nothing) content_blocks = Vector{messageContent}() if content_from_response isa Vector @@ -662,12 +669,22 @@ function _extractToolCalls(response) elseif get(block, "type", "") == "tool_calls" # tool_calls blocks — don't add text content for these elseif get(block, "type", "") == "reasoning" - push!(content_blocks, textContent(get(block, "text", ""))) + push!(content_blocks, reasoningContent(get(block, "text", ""))) else push!(content_blocks, textContent(get(block, "text", ""))) end end end + elseif content_from_response isa AbstractString && !isempty(content_from_response) + push!(content_blocks, textContent(content_from_response)) + end + + # Format 1 fallback: response["message"]["content"] as plain string + if isempty(content_blocks) && msg !== nothing && msg isa AbstractDict + msg_content = get(msg, "content", nothing) + if msg_content isa AbstractString && !isempty(msg_content) + push!(content_blocks, textContent(msg_content)) + end end # Combine reasoning_content field + content array blocks, deduplicating reasoning diff --git a/src/type.jl b/src/type.jl index d7eee74..bc224af 100644 --- a/src/type.jl +++ b/src/type.jl @@ -4,8 +4,8 @@ messageContent, agentMessage, agent, # Model types modelCost, llmModel, llmUsage, - # Message content types - textContent, imageContent, + # Message content types + textContent, imageContent, reasoningContent, # Message types userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage, # Tool types @@ -31,6 +31,13 @@ using GeneralUtils const Timestamp = DateTime +struct agentToolCall # A tool invocation from the LLM + type::String # Always "function" + id::String # Unique tool call identifier + name::String # Tool name + arguments::Dict{String, Any} # Parsed tool arguments +end + # ------------------------------------------------------------------------------------------------ # # LLM model info # # ------------------------------------------------------------------------------------------------ # @@ -75,6 +82,10 @@ struct imageContent <: messageContent # Image message content mimeType::String # MIME type (e.g., "image/png") end +struct reasoningContent <: messageContent # LLM reasoning/thinking content + text::String # The reasoning text +end + # ------------------------------------------------------------------------------------------------ # # Message types # @@ -404,14 +415,6 @@ function agentState( end -struct agentToolCall # A tool invocation from the LLM - type::String # Always "function" - id::String # Unique tool call identifier - name::String # Tool name - arguments::Dict{String, Any} # Parsed tool arguments -end - - """ Context for preparing the next conversation turn. diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl index 94abd46..6540f26 100644 --- a/test/_extractToolCalls.jl +++ b/test/_extractToolCalls.jl @@ -44,7 +44,7 @@ import YiemAgent.agentCore: _extractToolCalls @test assistant_msg.role == "assistant" @test assistant_msg.stopReason == "tool_calls" @test length(assistant_msg.content) == 1 - @test assistant_msg.content[1] isa textContent + @test assistant_msg.content[1] isa reasoningContent @test assistant_msg.content[1].text == "Let me check the weather." end @@ -241,7 +241,9 @@ import YiemAgent.agentCore: _extractToolCalls @test has_toolcalls == false @test length(tc_list) == 0 @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1] isa reasoningContent @test assistant_msg.content[1].text == "Thinking..." + @test assistant_msg.content[2] isa textContent @test assistant_msg.content[2].text == "Here's the answer." end @@ -307,7 +309,9 @@ import YiemAgent.agentCore: _extractToolCalls ) has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1] isa reasoningContent @test assistant_msg.content[1].text == "internal thought" + @test assistant_msg.content[2] isa textContent @test assistant_msg.content[2].text == "output" end -- 2.52.0 From 00447e4ddef75a1e570a135322b20504f2a06081 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 18:25:26 +0700 Subject: [PATCH 090/112] update --- src/agentCore.jl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 09c4297..399b19a 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -224,6 +224,8 @@ function _agentLoop(agent::yiemAgent) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) end + processingTask = nothing # reset + result = nothing # reset else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") result = agent._state.messages[end] @@ -233,7 +235,6 @@ function _agentLoop(agent::yiemAgent) agent.agentEventSink(filtered_content[1].text) end processingTask = nothing # reset - newUserMsg = nothing # reset result = nothing # reset end end @@ -263,6 +264,11 @@ function _agentLoop(agent::yiemAgent) # spawn new _processMessage() if it is not already running. if processingTask === nothing agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") + # discard all messages in followUpChannel + while isready(agent.followUpChannel) + _ = take!(agent.followUpChannel) + end + # Dispatch message through the processing pipeline processingTask = @spawn _processMessage( processMessageInputCh, -- 2.52.0 From c7abf844eaceb9e83e82b89125d39ad3853fafd5 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 20:14:54 +0700 Subject: [PATCH 091/112] update --- src/agentCore.jl | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 399b19a..58670f6 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -228,11 +228,17 @@ function _agentLoop(agent::yiemAgent) result = nothing # reset else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") - result = agent._state.messages[end] - filtered_content = [c for c in result.content if !(c isa reasoningContent)] - put!(agent.outputChannel, result) - if !isempty(filtered_content) && filtered_content[1] isa textContent - agent.agentEventSink(filtered_content[1].text) + result = deepcopy(agent._state.messages[end]) + + # filter out reasoningContent in-place + filter!(c -> !(c isa reasoningContent), result.content) + + # format output + respondToUI = _assistantMessageToOpenAI(result) + + put!(agent.outputChannel, respondToUI) + if !isempty(result.content) && result.content[1] isa textContent + agent.agentEventSink(result.content[1].text) end processingTask = nothing # reset result = nothing # reset -- 2.52.0 From 5829c82d0507509e8e02da110e9fde42f206a777 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 17 Aug 2026 03:04:10 +0700 Subject: [PATCH 092/112] update --- src/YiemAgent.jl | 2 + src/agentCore.jl | 20 +- src/tools/getTime.jl | 2 +- src/tools/getWeather.jl | 2 +- src/tools/searchWine.jl | 397 ++++++++ src/tools/writeTool.jl | 2 +- src/type.jl | 2 + src/utils.jl | 4 +- src_OLD/OLD_interface.jl | 1400 --------------------------- src_OLD/OLD_type.jl | 375 -------- src_OLD/llmfunction.jl | 1925 -------------------------------------- 11 files changed, 418 insertions(+), 3713 deletions(-) create mode 100644 src/tools/searchWine.jl delete mode 100644 src_OLD/OLD_interface.jl delete mode 100644 src_OLD/OLD_type.jl delete mode 100644 src_OLD/llmfunction.jl diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 11de2b9..599b859 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -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 diff --git a/src/agentCore.jl b/src/agentCore.jl index 58670f6..e02ce2d 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -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 diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 748d5e5..09ea31f 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -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 diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 5cca9cd..4f73239 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -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...") diff --git a/src/tools/searchWine.jl b/src/tools/searchWine.jl new file mode 100644 index 0000000..ca1a8fc --- /dev/null +++ b/src/tools/searchWine.jl @@ -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 = "\n\n$table_schema\n\n\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 diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index 8524772..6a913b3 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -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 diff --git a/src/type.jl b/src/type.jl index bc224af..0411748 100644 --- a/src/type.jl +++ b/src/type.jl @@ -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 """ diff --git a/src/utils.jl b/src/utils.jl index 29ac6a2..d7aa1fd 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -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 diff --git a/src_OLD/OLD_interface.jl b/src_OLD/OLD_interface.jl deleted file mode 100644 index fc74a9f..0000000 --- a/src_OLD/OLD_interface.jl +++ /dev/null @@ -1,1400 +0,0 @@ -module interface - -export addNewMessage, conversation, decisionMaker, reflector, generatechat, - generalconversation, detectWineryName, generateSituationReport - -using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames -using GeneralUtils -using ..type, ..util, ..llmfunction - -# ------------------------------------------------------------------------------------------------ # -# pythoncall setting # -# ------------------------------------------------------------------------------------------------ # -# Ref: https://github.com/JuliaPy/PythonCall.jl/issues/252 -# by setting the following variables, PythonCall.jl will use: -# 1. system's python and packages installed by system (via apt install) -# or 2. conda python and packages installed by conda -# if these setting are not set (comment out), PythonCall will use its own python and packages that -# installed by CondaPkg.jl (from env_preparation.jl) -# ENV["JULIA_CONDAPKG_BACKEND"] = "Null" # set condapkg backend = none -# systemPython = split(read(`which python`, String), "\n")[1] # system's python path -# 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 --------------------------------------------- # - - -macro executeStringFunction(functionStr, args...) - # Parse the function string into an expression - func_expr = Meta.parse(functionStr) - - # Create a new function with the parsed expression - function_to_call = eval(Expr(:function, - Expr(:call, func_expr, args...), func_expr.args[2:end]...)) - - # Call the newly created function with the provided arguments - function_to_call(args...) -end - - - -""" Think and choose action - -# Arguments - - `config::T1` - config - - `state::T2` - a game state - -# Keyword Arguments - -# Return - - `thoughtdict::Dict` - -# Example -```jldoctest -julia> result = decisionMaker(agent) - -OrderedDict{String, Any} with 4 entries: - "plan" => "The user provided an image of a sparkling white wine (Asolo Prosecco Bella Principessa from Italy) and requested a search for similar wines in the inventory. According to store guidelines, I must st… - "action_name" => "SEARCH_WINE_DATABASE" - "action_input" => "Sparkling white wine from Italy" - "action_result" => "1) winery: Terrazze dell Etna, wine_name: Rose Brut. -``` -""" -function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 - ) where {T<:agent} - @info "YiemAgent decisionMaker() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - requiredKeys = ["plan", "action_name", "action_input"] - context = - """ - - - """ - - # add context to text of the latest message (in the front). - # use for loop because in openai format, each msg may contain both text and image. - for d in a.chathistory[end]["content"] - if d["type"] == "text" - d["text"] = context * d["text"] - break - end - end - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - """ - { - "model": "your-model.gguf", - "messages": [ ... ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "agent_action", - "strict": true, - "schema": { - "type": "object", - "properties": { - "think": { - "type": "string", - "description": "Your step-by-step reasoning process. Explain why you are choosing this action." - }, - "action_name": { - "type": "string", - "enum": ["search_web", "getWeather", "calculate_math"], - "description": "The exact name of the tool to execute." - }, - "action_input": { - "type": "object", - "properties": { - "query": { "type": ["string", "null"], "description": "For search_web" }, - "location": { "type": ["string", "null"], "description": "For getWeather" }, - "equation": { "type": ["string", "null"], "description": "For calculate_math" } - }, - "required": ["query", "location", "equation"], - "additionalProperties": false - } - }, - "required": ["think", "action_name", "action_input"], - "additionalProperties": false - } - } - } - } - """ - - # strict output format - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict("type"=> "string"), - "action_name"=> Dict("type"=> "string"), - "action_input"=> Dict("type"=> "string"), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", - "messages"=> a.chathistory, - "temperature"=> 0.7, - "response_format"=> response_format, - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.remove_french_accents(response) - # think, response = GeneralUtils.extractthink(response) - - # dollar sign in Julia means string interpolation - while occursin('$', response) - response = replace(response, '$' => "USD") - end - - # responsedict = nothing - # try - # responsedict = Serde.parse_yaml(response) - # catch e - # println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - # # check whether all answer's key points are in responsedict - # println("\n---") - # println(responsedict) - # println("---\n") - # ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - - # if !ispass - # errornote = errormsg - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)-> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - responsedict = JSON.parse(response) - - if responsedict["action_input"] == "CHAT_BOX" && - occursin("similar", responsedict["action_input"]) - - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - @info "YiemAgent decisionMaker() end " @__LINE__ - return responsedict - end - - # in case decisionMaker failed, force to use generatechat!() - responsedict = OrderedDict( - "plan"=> "N/A", - "action_name"=> "CHAT_BOX", - "action_input"=> "N/A" - ) - return responsedict -end - - - -""" Assigns a scalar value to each new child node to be used for selec- -tion and backpropagation. This value effectively quantifies the agent's progress in task completion, -serving as a heuristic to steer the search algorithm towards the most promising regions of the tree. - -# Arguments - - `state<:AbstractDict` - one of Yiem's agent - - `text2textInstructLLM::Function` - A function that handles communication to LLM service - -# Return - - `score::Integer` - -# Example -```jldoctest -julia> -``` - -# Signature -""" -function evaluator(a::T1, timeline, decisiondict, evaluateecontext - ) where {T1<:agent} - - systemmsg = - """ - - - You are a master sommelier of an online wine store. - - - - Under your supervision, a trainee sommelier is engaging with a store customer. Each time the customer speaks, the trainee will assess the situation, determine the next course of action, and pause to await your guidance before proceeding. - - - - Improve a trainee sommelier decision based on the store policy and guidelines while ensuring seamless interactions between the trainee and customers. - - - - trajectory: A conversation between your trainee and the customer that have occurred up until now - - evaluatee_context: The context that evaluatee use to make a decision - - evaluatee_decision: The decision made by the evaluatee, consists of the following elements: - "plan" is the trainee's plan - "action_name" is the name of the action taken, which can be one of the available tool name. - "action_input" is the input to the action. - - - - Use only infomation provided by the store policy and guidelines as a bedrocks for your response. - - - - The trainee's plan, action_name, and action_input must be logically consistent - - The trainee's action_input should be in a proper format as specified by the tools. - - The trainee's action name and action input should make sense. For example, if the trainee isn't finished talking, he shouldn't use the END_CONVER_GUIDELINE tool. - - - 1) trajectory_evaluation: Analyze the trajectory of a solution to answer the user's original question. - - Evaluate the correctness of each section and the overall trajectory based on the given question. - - Provide detailed reasoning and analysis, focusing on the latest thought, action, and observation. - - Incomplete trajectory are acceptable if the thoughts and actions up to that point are correct, even if the final answer isn't reached. - - Do not generate additional thoughts or actions. - 2) decision_evaluation: - - Examine how the trainee's decisions align with the store's policies and guidelines before proceeding. - 3) suggestion: Based store policy and guidelines, provide a suggestion for the immediate decision step only. - 4) approval: Can be "yes" or "no". "no" if the suggestion contradict the trainee's decision; otherwise, it is "yes". - - - - { - "trajectory_evaluation": "...", - "decision_evaluation": "...", - "suggestion": "...", - "approval": "...", - } - - - Let's begin! - """ - requiredKeys = [:trajectory_evaluation, :decision_evaluation, :approval, :suggestion] - errornote = "N/A" - - for attempt in 1:10 - evaluateecontext = replace(evaluateecontext, "" => "") - evaluateecontext = replace(evaluateecontext, "" => "") - - context = - """ - - - $timeline - - - $evaluateecontext - - - {plan: $(decisiondict["plan"]), action_name: $(decisiondict["action_name"]), action_input: $(decisiondict["action_input"])} - - P.S. $errornote - - """ - - unformatPrompt = - [ - Dict("name" => "system", "text" => systemmsg), - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(unformatPrompt, a.llmFormatName) - # add info - prompt = prompt * context - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - response = GeneralUtils.remove_french_accents(response) - # response = replace(response, '$'=>"USD") - think, response = GeneralUtils.extractthink(response) - - responsedict = nothing - try - responsedict = copy(JSON.parsefile(response)) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent evaluator() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if accepted_as_answer ∉ ["yes", "no"] # [PENDING] add errornote into the prompt - # error("generated accepted_as_answer has wrong format") - # end - - println("\nEvaluator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(Dict(responsedict)) - return responsedict - end - error("Evaluator failed to generate an evaluation, Response: \n$response\n<|End of error|>") -end - -""" Chat with llm. - -# Example userinput - -image_path = "test/large_image.png" -image_bytes = read(image_path) -base64_string = base64encode(image_bytes) - -# 2. Match the MIME type according to your file extension (e.g., png, jpeg) -mime_type = "image/png" -data1_uri = "data:;base64," - -# 3. Construct payload with the Data URI -message => Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Describe this image for me"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data_uri) - ) - ] - ) - -""" -function conversation(a::sommelier; userinput::Union{Dict{String, Any}, JSON.Object{String, Any}}, - maximumMsg=50, max_think_loop::Integer=3) - - @info "YiemAgent conversation() start " @__LINE__ - userinput = GeneralUtils.dictify(userinput; keytype=String, sort_order=["text"]) - - # find text in usermsg - usertext = nothing - for (i, d) in enumerate(userinput["content"]) - if d["type"] == "text" - d["text"] = GeneralUtils.remove_french_accents(d["text"]) - usertext = d["text"] - end - end - - if usertext == "newtopic" - clearhistory(a) - return "Okay. What shall we talk about?" - else - - # add usermsg to a.chathistory but how do I handle images? - addNewMessage(a, "user", userinput; maximumMsg=maximumMsg) - - # thinking loop until AI wants to communicate with the user - loopcount = 0 - while true - loopcount += 1 - if loopcount > max_think_loop - - thoughtdict, result_raw = generatechat!(a) - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - return response_to_frontend - end - - - thoughtdict, result_raw = think(a) - - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - """ intended message to send to frontend should have the following format. - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => "assistant_text_response"), - Dict( - "type" => "items_info", - "items_info" => [ - Dict( - "wine_name"=> "wine name 1", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - Dict( - "wine_name"=> "wine name 2", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - ] - ), - ] - ) - """ - - - return response_to_frontend - else # still in action - - action_name = thoughtdict["action_name"] - action_input = thoughtdict["action_input"] - - action_call = Dict{String, Any}( - "role" => "action_call", - "content" => [Dict("type" => "text", "text" => "{action_name: $action_name, action_input: $action_input}"),] - ) - - addNewMessage(a, "action_call", action_call; maximumMsg=maximumMsg) - - action_result = thoughtdict["action_result"] - actionresult = Dict{String, Any}( - "role" => "action_result", - "content" => [Dict("type" => "text", "text" => "$action_result"),] - ) - - addNewMessage(a, "actionresult", actionresult; maximumMsg=maximumMsg) - @info "YiemAgent conversation() end think count $loopcount " @__LINE__ - end - end - end -end - - -""" -# Arguments - -# Return - -# Example -```jldoctest -julia> -``` - -""" -function think(a::T)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - # a.memory[:recap] = generateSituationReport(a, a.context["text"2textInstructLLM]; skiprecent=0) - @info "YiemAgent think() start " @__LINE__ - thoughtdict = decisionMaker(a) - @info "YiemAgent think() 1 " @__LINE__ - @show thoughtdict - println("---\n") - - result_raw = nothing - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - - # sometime CHAT_BOX input is too short. - # if thoughtdict["action_input] < 20 character, use generatechat!() - if length(thoughtdict["action_input"]) < 20 - thoughtdict, result_raw = generatechat!(a) - else - thoughtdict["action_result"] = "Action result is the next user dialogue." - result_raw = thoughtdict["action_input"] - end - - elseif thoughtdict["action_name"] == "END_CONVER_GUIDELINE" - - thoughtdict, result_raw = end_conversation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] ∈ ["WINE_PRESENTATION_GUIDELINE"] - - thoughtdict, result_raw = wine_presentation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] == "SEARCH_WINE_DATABASE" - - thoughtdict, result_raw = search_wine_database!(a, thoughtdict; useSQLLLM=false) - if result_raw !== nothing && result_raw isa Vector - if haskey(a.memory["shortmem"], "items_info") - append!(a.memory["shortmem"]["items_info"], result_raw) - else - a.memory["shortmem"]["items_info"] = result_raw - end - end - - else - - error("condition is not defined ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - @info "YiemAgent think() end " @__LINE__ - # @show thoughtdict - println("---\n") - return (thoughtdict=thoughtdict, result_raw=result_raw) -end - -function chatbox!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - thoughtdict["action_result"] = "Action result is the next user dialogue." - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function end_conversation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide customer with store contact info and business hours - - Invite customer to comeback - - Business Hours: everyday 9.00-20.00 - Tel. 0863055790 - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function wine_presentation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide detailed introductions of the wines you've found to the user. - - Explain how the wine could match the user's intention and what its effects might mean for the user's experience. - - If multiple wines are available, highlight their differences and provide a comprehensive comparison of how each option aligns with the user's intention and what the potential effects of each option could mean for the user's experience. - - Provide your personal recommendation and provide a brief explanation of why you recommend it. - - People don't describe wine quality level in numbers so use convertion_table if neccessary - - Intensity level: - 1 to 2: May correspond to "light-bodied" or a similar description. - 2 to 3: May correspond to "med light bodied", "medium light" or a similar description. - 3 to 4: May correspond to "medium bodied" or a similar description. - 4 to 5: May correspond to "med full bodied", "medium full" or a similar description. - 4 to 5: May correspond to "full bodied" or a similar description. - Sweetness level: - 1 to 2: May correspond to "dry", "no sweet" or a similar description. - 2 to 3: May correspond to "off dry", "less sweet" or a similar description. - 3 to 4: May correspond to "semi sweet" or a similar description. - 4 to 5: May correspond to "sweet" or a similar description. - 4 to 5: May correspond to "very sweet" or a similar description. - Tannin level: - 1 to 2: May correspond to "low tannin" or a similar description. - 2 to 3: May correspond to "semi low tannin" or a similar description. - 3 to 4: May correspond to "medium tannin" or a similar description. - 4 to 5: May correspond to "semi high tannin" or a similar description. - 4 to 5: May correspond to "high tannin" or a similar description. - Acidity level: - 1 to 2: May correspond to "low acidity" or a similar description. - 2 to 3: May correspond to "semi low acidity" or a similar description. - 3 to 4: May correspond to "medium acidity" or a similar description. - 4 to 5: May correspond to "semi high acidity" or a similar description. - 4 to 5: May correspond to "high acidity" or a similar description. - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - - -#PENDING -function generatechat!(a::T; maxattempt::Integer=10 - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - @info "YiemAgent generatechat!() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user immediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - # situation - You are continuing the conversation with the user. - - # your role - Your name is $(a.name). You are a helpful sommelier for website-based $(a.retailername)'s wine store. You are working under your mentor supervision. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, continuing conversation with the customer using CHAT_BOX action. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", action_name must be CHAT_BOX. - 3) "action_input", Dialogue you want to chat with the user according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - chathistory = deepcopy(a.chathistory[2:end]) # use deep copy because I want to replace system msg - pushfirst!(chathistory, system_msg) - - requiredKeys = ["plan", "action_name", "action_input"] - - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - for attempt in 1:maxattempt - if attempt > 1 - println("\nYiemAgent generatechat() attempt $attempt/$maxattempt ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict( - "type"=> "string", - "description" => "Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.", - ), - "action_name"=> Dict( - "type"=> "string", - "description" => "action_name must be CHAT_BOX", - ), - "action_input"=> Dict( - "type"=> "string", - "description" => "Dialogue you want to chat with the user according to your plan.", - ), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => chathistory, - "temperature" => 0.7, - "response_format"=> response_format, - ) - - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - - response = strip(response) - - responsedict = nothing - if occursin(requiredKeys[2], response) - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - else - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - responsedict["action_result"] = "Action result is the next user dialogue." - @info "YiemAgent generatechat!() end " @__LINE__ - return (thoughtdict=responsedict, result_raw=responsedict["action_input"]) - end - @info "YiemAgent generatechat() failed to generate a thought " @__LINE__ - error("YiemAgent generatechat() failed to generate a thought ", response) -end - - -function generatequestion(a, text2textInstructLLM::Function, timeline)::String - systemmsg = - """ - Your role: - Your name is $(a.name). You are a helpful English-speaking, website-based sommelier for $(a.retailername)'s online store currently talking with the user. - Your goal includes: - 1) Help the user select the best wines from your inventory that align with the user's preferences - 2) Thanks the user when they don't need any further assistance and invite them to comeback next time - - Your responsibility includes: - 1) From your point of view as a sommelier helping the user, ask yourself multiple questions based on the current situation - - Your responsibility does NOT includes: - 1) Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - 2) Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - 3) Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - At each round of conversation, you will be given the info: - Additional info: ... - Your recent events: latest 5 events of the situation - - You must follow the following guidelines: - - Your question should be specific, self-contained and not require any additional context. - - Once the user has chose their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - You should follow the following guidelines: - - Focus on the latest conversation - - If the user interrupts, prioritize the user - - If you don't already know, find out the user's budget - - If you don't already know, find out the type of wine the user is looking for, such as red, white, sparkling, rose, dessert, fortified - - If you don't already know, find out the occasion for which the user is buying wine - - If you don't already know, find out the characteristics of wine the user is looking for, such as tannin, sweetness, intensity, acidity - - If you don't already know, find out what food will be served with wine - - If you haven't already, introduce the wines you found in the database to the user first - - Generally speaking, your inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - All wines in your inventory are always in stock. - - Engage in conversation to indirectly investigate the customer's intention, budget and preferences before checking your inventory. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - Medium and full-bodied red wines are bad with spicy foods. - - If a customer requests information about discounts, quantity, rewards programs, promotions, delivery options, boxes, gift wrapping, packaging, or personalized messages, please inform them that they can contact our sales team at the store. - - You should then respond to the user with: - 1) Thought: State your thought about the current situation - 2) Q: "Ask yourself" at least three, but no more than five, questions about the situation from your perspective. - 3) A: Given the situation, "answer to yourself" the best you can. Do not generate any extra text after you finish answering all questions - - You must only respond in format as described below: - Q1: ... - A1: ... - Q2: ... - A2: ... - ... - - Here are some examples: - Q: What the user is looking for? - A: The user is asking for a MPV car with 7-seat - Q: What do I know? - A: The user is looking for a car with 7-seat. Our dealer sell these kind of cars - Q: What brands the user prefer? - A: I don't know. The user didn't mentioned that. Let's find out. - Q: What else do I need to know before proceeding? - A: I don't know about the user budget, car's color, and other user's preferences yet. Let's find out more about the user's preferences. - Q: I'm still lacking information regarding the user's preferences for the powertrain. I've asked the user twice already, but perhaps they're not familiar with this. What should I do. - A: I'll proceed without asking the user about the powertrain. - Q: The user is buying for her husband, should I dig in to get more information? - A: Yes, I should. So that I have better idea about the user's preferences. - Q: Why the user saying this? - A: The user does not want an SUV because it does not have sliding doors - Q: The user is asking for a cappuccino. Do I have it at my cafe? - A: No I don't have. - Q: Since I don't have a cappuccino but I have a Late, should I ask if they are okay with that? - A: Yes, I should. - Q: Are they allergic to milk? - A: Since they mentioned a cappuccino before, it seems they are not allergic to milk. - Q: Have I checked the inventory yet? - A: No. I need more information from the user including ... - Q: What else do I need to know? - A: ... - Q: Should I present my item to the user? - A: Not yet, I will need to check my inventory first. - Q: Should I check our inventory now? - A: ... - Q: What the user intend to do with the car? - A: I don't know yet. Let's ask the user. - Q: What do I have in our inventory? - A: ... - Q: Which items are within the user price range? And which items are out of the user price rance? - A: ... - Q: Do I have what the user is looking for in our stock? - A: ... - Q: Am I certain about the information I'm going to share with the user, or should I verify the information first? - A: ... - Q: What should I do? - A: ... - Q: What shouldn't I do? - A: ... - Q: what kind of car suitable for off-road trip? - A: A four-wheel drive SUV is a good choice for off-road trips. - Q: What car specification would satisfy the user's needs? - A: The user is seeking an eco-friendly vehicle that accommodates seven passengers, including seniors and children, with prioritized accessibility and efficient refueling. While electric vehicles (EVs) offer eco-friendly benefits, their long charging times make hybrid models more practical for fast refueling. Additionally, a lower ground level is essential for ease of entry/exit for seniors and children. A hybrid multi-purpose vehicle (MPV) emerges as the optimal solution, balancing sustainability, seating capacity, accessibility, and refueling efficiency. - - Let's begin! - """ - - header = ["Q1:"] - dictkey = ["q1"] - - # context = - # if length(a.memory["shortmem"]["available_wine"]) != 0 - # "Available wines you've found in your inventory so far: $(availableWineToText(a.memory["shortmem"]["available_wine"]))" - # else - # "N/A" - # end - database_search_result = a.memory["shortmem"]["db_search_result"] - - # recent_ind = GeneralUtils.recentElementsIndex(length(a.memory[:events]), recent) - # recentevents = a.memory[:events][recent_ind] - # timeline = createTimeline(recentevents; eventindex=recent_ind) - errornote = "N/A" - response = nothing # store for show when error msg show up - - # recap = - # if length(a.memory[:recap]) <= recent - # "N/A" - # else - # recapkeys = keys(a.memory[:recap]) - # recapkeys_vec = [i for i in recapkeys] - # recapkeys_vec = recapkeys_vec[1:end-recent] - # tempmem = OrderedDict() - # for (k, v) in a.memory[:recap] - # if k ∈ recapkeys_vec - # tempmem[k] = v - # end - # end - - # GeneralUtils.dictToString(tempmem) - # end - - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.5, - ) - - for attempt in 1:10 - if attempt > 1 - println("\nYiemAgent generatequestion() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = - """ - Additional info: $database_search_result - Your recent events: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = text2textInstructLLM(prompt; - modelsize="medium", llmkwargs=llmkwargs, senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - - # make sure generatequestion() don't have wine name that is not from retailer inventory - # check whether an agent recommend wines before checking inventory or recommend wines - # outside its inventory - # ask LLM whether there are any winery mentioned in the response - mentioned_winery = detectWineryName(a, response) - if mentioned_winery != "None" - mentioned_winery = String.(strip.(split(mentioned_winery, ","))) - - # check whether the wine is in event - isWineInEvent = false - for winename in mentioned_winery - for event in a.memory["events"] - if event["observation"] !== nothing && occursin(winename, event["observation"]) - isWineInEvent = true - break - end - end - end - - # if wine is mentioned but not in timeline or shortmem, - # then the agent is not supposed to recommend the wine - if isWineInEvent == false - errornote = "Your previous attempt mentioned wines that are not in your inventory which is not allowed." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - end - - q_number = count("Q", response) - - # check for valid response - if q_number < 1 - errornote = "Your previous attempt has too few questions." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - # check whether "A1" is in the response, if not error. - elseif !occursin("A1:", response) - errornote = "Your previous attempt does not have A1:" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - if 0 ∈ values(detected_kw) - errornote = "\nYour previous attempt did not have all points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - response = "Q1: " * responsedict["q1"] - println("\nYiemAgent generatequestion() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return response - end - error("YiemAgent generatequestion() failed to generate a response ", response) -end - - -function generateSituationReport(a, text2textInstructLLM::Function; skiprecent::Integer=0 - )::OrderedDict - - systemmsg = - """ - You are an assistant being in the given events. - Your task is to writes a summary for each event seperately into an ongoing, interleaving series. - - At each round of conversation, you will be given the situation: - Total events: number of events you need to summarize. - Events timeline: ... - Context: ... - - You should follow the following guidelines: - - Use the word "user" and "assistant" instead of their name in the report - - You should then respond to the user with the following: - Event: a detailed summary for each event without exaggerated details. - - You must only respond in format as described below: - Event_1: ... - Event_2: ... - ... - - Here are some examples: - Event_1: The user ask me about where to buy a toy. - Event_2: I told the user to go to the store at 2nd floor. - - Event_1: The user greets the assistant by saying 'hello'. - Event_2: The assistant respond warmly and inquire about how he can assist the user. - - Let's begin! - """ - - header = ["Event_$i:" for i in eachindex(a.memory["events"])] - dictkey = lowercase.(["Event_$i" for i in eachindex(a.memory["events"])]) - - ind = GeneralUtils.nonRecentElementsIndex(length(a.memory["events"]), skiprecent) - events = a.memory["events"][ind] - timeline = createTimeline(events) - - errornote = "N/A" - response = nothing # store for show when error msg show up - for attempt in 1:10 - if attempt > 1 # use to prevent LLM generate the same respond over and over - println("\nYiemAgent generateSituationReport() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = """ - Total events: $(length(events)) - Events timeline: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3") - - response = text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, "qwen3") - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - kwvalue = [i for i in values(detected_kw)] - zeroind = findall(x -> x == 0, kwvalue) - missingkeys = [header[i] for i in zeroind] - if 0 ∈ values(detected_kw) - errornote = "$missingkeys are missing in your previous attempt" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "Your previous response has duplicated events" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - println("\ngenerateSituationReport() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return responsedict - end - error("generateSituationReport failed to generate a response ", response) -end - - -function detectWineryName(a, text) - systemmsg = - """ - You are a sommelier of a wine store. - Your task is to identify and list any winery names mentioned in the provided text. - - At each round of conversation, you will be given the situation: - Text: a text describing the situation. - - Tips: - - Winery usually contains Château, Chateau, Domaine, Côte, Cotes, St. de, or a combination of these words. - - You should then respond to the user with: - Winery_names: A list of winery names mentioned in the text or "None" if no winery name is mentioned. - - You must only respond in format as described below: - Winery_names: ... - - Here are some examples: - Winery_names: Domaine Courbis, Chateau Lafite Rothschild, Matarromera Domaine Roulot, Château, Cotes - - Let's begin! - """ - - header = ["Winery_names:"] - dictkey = ["winery_names"] - - response = nothing # placeholder for show when error msg show up - - for attempt in 1:10 - usermsg = """ - Text: $text - """ - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - println("\ndetectWineryName() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - missingkeys = [k for (k, v) in detected_kw if v === nothing] - - if !isempty(missingkeys) - errornote = "$missingkeys are missing from your previous response" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum([length(i) for i in values(detected_kw)]) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - result = responsedict["winery_names"] - - return result - end - error("detectWineryName failed to generate a response") - end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module interface - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src_OLD/OLD_type.jl b/src_OLD/OLD_type.jl deleted file mode 100644 index d554f2a..0000000 --- a/src_OLD/OLD_type.jl +++ /dev/null @@ -1,375 +0,0 @@ -module type - -export agent, sommelier, companion, virtualcustomer, agentcontext - -using Dates, UUIDs, DataStructures, JSON, NATS -using GeneralUtils - -# ---------------------------------------------- 100 --------------------------------------------- # - - -mutable struct agentcontext - text2textInstructLLM::Function - getTextEmbedding::Function - executeSQL::Function - similarSQLVectorDB::Function - insertSQLVectorDB::Function - similarSommelierDecision::Function - insertSommelierDecision::Function - find_related_tables_for_user_question::Function - pg_conn_str::String - agentconfig::AbstractDict -end - -abstract type agent end - -mutable struct sommelier <: agent - name::String # agent name - id::String # agent id - retailername::String - retailerid::String - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context::agentcontext - llmFormatName::String -end - -""" A sommelier agent. - -# Arguments - - `context::agentcontext` - Application context containing shared functions for LLM, SQL, and vector database operations. - -# Keyword Arguments - - `name::String` - Agent's name. Default: `"Assistant"` - - `id::String` - Agent's ID. Default: generated UUID string. - - `retailername::String` - Retailer name associated with the sommelier. Default: `"retailer_name"` - - `maxHistoryMsg::Integer` - Maximum history messages. Default: `20` - - `chathistory::Vector{Dict{String, String}}` - Chat history. Default: empty vector. - - `llmFormatName::String` - LLM format name. Default: `"granite3"` - -# Return - - `sommelier`: An instantiated sommelier agent. - -# Example -```julia -julia> using YiemAgent -julia> context = agentcontext( - text2textInstructLLM, - getTextEmbedding, - executeSQL, - similarSQLVectorDB, - insertSQLVectorDB, - similarSommelierDecision, - insertSommelierDecision - ) -julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop") -``` -""" -function sommelier( - context::agentcontext, # agent functions, db connect and other context - ; - name::String= "Assistant", - id::String= string(uuid4()), - retailername::String= "not specified", - retailerid::String= "not specified", - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(), - llmFormatName::String= "granite3" - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - "winestock"=> Dict( - "description" => "A handy tool for searching wine in your inventory that match the user preferences.", - "input" => """Input is a JSON-formatted string that contains a detailed and precise search query.{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}""", - "output" => """Output are wines that match the search query in JSON format.""", - ), - ) - - """ Memory - - Chat history use openai format as follow: - - image1_path = "test/large_image.png" --- - image1_bytes = read(image1_path) | this part must be done - image1_base64_string = base64encode(image1_bytes) | in frontend - mime_type = "image/png" | not in agent code - data1_uri = "data:;base64," --- - - chathistory= [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => "You are a helpful assistant"), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => " - LLM context here... - - Do you know this wine? Just give me brief intro." - ), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ), - ] - ), - ] - - shortmem = Dict( - "1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - "2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - ... - ) - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}(), - "scratchpad"=> "", - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = sommelier( - name, - id, - retailername, - retailerid, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - User usually ask for something similar. This means you should use the search term based on the profile they like. - - # situation - You are having conversation with a customer. - - # your role - Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name - 3) "action_input", The input to the action you are about to perform according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - "SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. - Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD." - Example query 2: "Red or white wine, medium tannin, price under 700 USD" - Example query 3: "white wine from Tuscany, Italy or Bordeaux, France - "WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - "END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - push!(newAgent.chathistory, system_msg) - - return newAgent -end - - -mutable struct virtualcustomer <: agent - name::String # agent name - id::String # agent id - systemmsg::String # system message - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context # NamedTuple of functions - llmFormatName::String -end - -function virtualcustomer( - context, # NamedTuple of functions - ; - name::String= "Assistant", - id::String= string(uuid4()), - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(), - llmFormatName::String= "granite3", - systemmsg::String= - """ - Your name: $name - Your sex: Female - Your role: You are a helpful assistant. - You should follow the following guidelines: - - Focus on the latest conversation. - - Your like to be short and concise. - - Let's begin! - """, - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - ) - - """ Memory - Ref: Chat prompt format is openai - chathistory = [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => system_msg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - ] - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}( - ), - "scratchpad"=> "", - "events"=> Vector{Dict{String, Any}}(), - "state"=> Dict{String, Any}( - ), - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = virtualcustomer( - name, - id, - systemmsg, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - - return newAgent -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module type \ No newline at end of file diff --git a/src_OLD/llmfunction.jl b/src_OLD/llmfunction.jl deleted file mode 100644 index e7c8903..0000000 --- a/src_OLD/llmfunction.jl +++ /dev/null @@ -1,1925 +0,0 @@ -module llmfunction - -export virtualWineUserChatbox, jsoncorrection, search_wine_database!, # recommendbox, - virtualWineUserRecommendbox, userChatbox, userRecommendbox, extractWineAttributes_1, - extractWineAttributes_2, paraphrase, SQLexecution - -using HTTP, JSON, URIs, Random, PrettyPrinting, UUIDs, Dates, DataFrames, DataStructures, - Base64, LibPQ, NATS -using GeneralUtils, SQLLLM -using ..type, ..util - -# ---------------------------------------------- 100 --------------------------------------------- # - - -""" -Chat with a virtual wine customer for recommendations. - -Formats the input for the configured LLM model and communicates via MQTT -to receive a response tuple containing text, selection, reward, and terminal status. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with `config` containing `externalservice` - and `mqttServerInfo` keys -- `input::T2`: Text to send to the virtual wine customer LLM - -# Returns -- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of - `(response_text, select, reward, isterminal)` where `select` may be `Nothing` - -# Notes -- Requires `a.config["externalservice"]["virtualWineCustomer_1"]` with `llminfo` and `mqtttopic`. -- Requires `a.config["mqttServerInfo"]` with `broker` and `port`. -- Only supports `llama3instruct` model name (other models throw an error). -- Uses `GeneralUtils.sendReceiveMqttMsg` with a 120-second timeout. - -# TODO -- Add `recommend()` to compare wines -""" -function virtualWineUserRecommendbox(a::T1, input - )::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:agent} - - # put in model format - virtualWineCustomer = a.config["externalservice"]["virtualWineCustomer_1"] - llminfo = virtualWineCustomer["llminfo"] - prompt = - if llminfo["name"] == "llama3instruct" - formatLLMtext_llama3instruct("assistant", input) - else - error("llm model name is not defied yet $(@__LINE__)") - end - - # send formatted input to user using GeneralUtils.sendReceiveMqttMsg - msgMeta = GeneralUtils.generate_msgMeta( - virtualWineCustomer["mqtttopic"], - senderName= "virtualWineUserRecommendbox", - senderId= a.id, - receiverName= "virtualWineCustomer", - mqttBroker= a.config["mqttServerInfo"]["broker"], - mqttBrokerPort= a.config["mqttServerInfo"]["port"], - msgId = "dummyid" #CHANGE remove after testing finished - ) - - outgoingMsg = Dict( - "msgMeta"=> msgMeta, - "payload"=> Dict( - "text"=> prompt, - ) - ) - - result = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120) - response = result["response"] - - return (response["text"], response["select"], response["reward"], response["isterminal"]) -end - - - -""" -Chatbox for conversing with a virtual wine customer AI. - -Formats the chat history with a system prompt, sends it via MQTT to a text2text instruct -LLM service, and parses the JSON response. Retries up to 5 times on failure. - -# Arguments -- `config::T1`: Configuration dictionary (subtype of `AbstractDict`) containing: - - `externalservice["text2text"]["mqtttopic"]`: MQTT topic for the LLM service - - `mqttServerInfo["broker"]`: MQTT broker address - - `mqttServerInfo["port"]`: MQTT broker port -- `input::T2`: Current sommelier message text (subtype of `AbstractString`) -- `virtualCustomerChatHistory`: Chat history vector of dictionaries with `"name"` and `"text"` keys - -# Returns -- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of - `(text, select, reward, isterminal)` representing the virtual customer's response - -# Notes -- The system prompt defines the virtual customer's persona and response format. -- Chat history role names are transformed: "user" → "you", "assistant" → "sommelier". -- Uses `jsoncorrection` to fix malformed LLM JSON responses. -- Retries up to 5 times on error before throwing. -- Uses `formatLLMtext` with `"llama3instruct"` format. - -# Examples -```jldoctest -julia> result = YiemAgent.virtualWineUserChatbox(config, sommelier_msg, history) -("I'd like something under $50", nothing, 0, false) -``` -""" -function virtualWineUserChatbox(config::T1, input::T2, virtualCustomerChatHistory - )::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:AbstractDict, T2<:AbstractString} - - previouswines = - """ - You have the following wines previously: - - """ - - systemmsg = - """ - You find yourself in a well-stocked wine store, engaged in a conversation with the store's knowledgeable sommelier. - You're on a quest to find a bottle of wine that aligns with your specific preferences and requirements. - - The ideal wine you're seeking should meet the following criteria: - 1. It should fit within your budget. - 2. It should be suitable for the occasion you're planning. - 3. It should pair well with the food you intend to serve. - 4. It should be of a particular type of wine you prefer. - 5. It should possess certain characteristics, including: - - The level of sweetness. - - The intensity of its flavor. - - The amount of tannin it contains. - - Its acidity level. - - Here's the criteria details: - { - "budget": 50, - "occasion": "graduation ceremony", - "food pairing": "Thai food", - "type of wine": "red", - "wine sweetness level": "dry", - "wine intensity level": "full-bodied", - "wine tannin level": "low", - "wine acidity level": "medium", - } - - You should only respond with "text", "select", "reward", "isterminal" steps. - "text" is your conversation. - "select" is an integer. Choose an option when presented with choices, or leave it null if none of the options satisfy you or if no choices are available. - "reward" is an integer, it can be three number: - 1) 1 if you find the right wine. - 2) 0 if you don’t find the ideal wine. - 3) -1 if you’re dissatisfied with the sommelier’s response. - "isterminal" can be false if you still want to talk with the sommelier, true otherwise. - - You should only respond in JSON format as describe below: - { - "text": "your conversation", - "select": null, - "reward": 0, - "isterminal": false - } - - Here are some examples: - - sommelier: "What's your budget? - you: - { - "text": "My budget is 30 USD.", - "select": null, - "reward": 0, - "isterminal": false - } - - sommelier: "The first option is Zena Crown and the second one is Buano Red." - you: - { - "text": "I like the 2nd option.", - "select": 2, - "reward": 1, - "isterminal": true - } - - Let's begin! - """ - -pushfirst!(virtualCustomerChatHistory, Dict("name"=> "system", "text"=> systemmsg)) - - # replace the :user key in chathistory to allow the virtual wine customer AI roleplay - chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}() - for i in virtualCustomerChatHistory - newdict = Dict() - newdict["name"] = - if i["name"] == "user" - "you" - elseif i["name"] == "assistant" - "sommelier" - else - i["name"] - end - - newdict["text"] = i["text"] - push!(chathistory, newdict) - end - - push!(chathistory, Dict("name"=> "assistant", "text"=> input)) - - # put in model format - prompt = formatLLMtext(chathistory, "llama3instruct") - prompt *= - """ - <|start_header_id|>you<|end_header_id|> - {"text" - """ - - pprint(prompt) - externalService = config["externalservice"]["text2textinstruct"] - - # send formatted input to user using GeneralUtils.sendReceiveMqttMsg - msgMeta = GeneralUtils.generate_msgMeta( - externalService["mqtttopic"], - senderName= "virtualWineUserChatbox", - senderId= string(uuid4()), - receiverName= "text2textinstruct", - mqttBroker= config["mqttServerInfo"]["broker"], - mqttBrokerPort= config["mqttServerInfo"]["port"], - msgId = string(uuid4()) # remove after testing finished - ) - - outgoingMsg = Dict( - "msgMeta"=> msgMeta, - "payload"=> Dict( - "text"=> prompt, - ) - ) - - attempt = 0 - for attempt in 1:5 - try - response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120) - _responseJsonStr = response["response"]["text"] - expectedJsonExample = - """ - Here is an expected JSON format: - { - "text": "...", - "select": "...", - "reward": "...", - "isterminal": "..." - } - """ - responseJsonStr = jsoncorrection(config, _responseJsonStr, expectedJsonExample) - responseDict = copy(JSON.parsefile(responseJsonStr)) - - text::AbstractString = responseDict["text"] - select::Union{Nothing, Number} = responseDict["select"] == "null" ? nothing : responseDict["select"] - reward::Number = responseDict["reward"] - isterminal::Bool = responseDict["isterminal"] - - if text != "" - # pass test - else - error("virtual customer not answer correctly") - end - - return (text, select, reward, isterminal) - catch e - io = IOBuffer() - showerror(io, e) - errorMsg = String(take!(io)) - st = sprint((io, v) -> show(io, "text/plain", v), stacktrace(catch_backtrace())) - println("") - @warn "Error occurred: $errorMsg\n$st" - println("") - end - end - error("virtualWineUserChatbox failed to get a response") -end - -""" -Search for wines in stock. - -Executes a wine search via SQL (either through SQLLLM or direct query), optionally fetching -and base64-encoding bottle images for each result. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing `executeSQL`, - `pg_conn_str`, and `agentconfig` -- `thoughtdict::AbstractDict`: A dictionary containing `action_input` (the search query string) - -# Keyword Arguments -- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query instead of direct SQL generation - -# Returns -- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}`: A named tuple with: - - `thoughtdict`: The input thoughtdict with `action_result` populated - - `result_raw`: Vector of wine dictionaries with image data, or `nothing` - -# Notes -- When `useSQLLLM=false`, performs hard SQL filtering followed by vector search. -- Fetches bottle images from `http://192.168.88.106:8080/` and encodes as base64. -- Uses `wine_search_term_classification` to extract search conditions. -- Requires PostgreSQL connection info from `a.context.agentconfig["externalservice"]["sommpanion_db"]`. - -# Examples -```jldoctest -julia> thoughtdict = OrderedDict("action_input" => "red wine under 50"); -julia> result = YiemAgent.search_wine_database!(agent, thoughtdict) -(thoughtdict=OrderedDict{String, Any}(...), result_raw=[Dict(...), ...]) -``` -""" -function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - println("\ncheckinventory order: $(thoughtdict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - if useSQLLLM - # add suppport for similarSQLVectorDB - textresult, result_raw = SQLLLM.query( - inventoryquery, - a.context.executeSQL, - a.context.text2textInstructLLM; - insertSQLVectorDB=a.context.insertSQLVectorDB, - similarSQLVectorDB=a.context.similarSQLVectorDB, - llmFormatName="qwen3") - thoughtdict["action_result"] = textresult - else - - # direct query with possible sql instead of SQLLLM. - hard_conditions, vector_search = wine_search_term_classification(a, thoughtdict["action_input"]) - - # do hard filter - # sql = generatesql(a, inventoryquery) - sql = predefined_wine_search_sql(hard_conditions) - @info "\nsql: $sql, \nvector_search: $vector_search" - textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql) - - # do vector search - vector_search_str = "" - for i in vector_search - vector_search_str = vector_search_str * " " * i["value"] - end - vector_search_str = String(strip(vector_search_str)) - vector_search_str = GeneralUtils.removestring(vector_search_str, ["%"]) - @show vector_search - @show vector_search_str - - - config = a.context.agentconfig - host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') - port = parse(Int, _port) - dbname = "winedb" - user = config["externalservice"]["sommpanion_db"]["user"] - password = config["externalservice"]["sommpanion_db"]["password"] - pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" - - #WORKING - # df = GeneralUtils.find_text_vector_similarity( - # vector_search_str, - # "wine", - # "tasting_notes_embedding", - # GeneralUtils.execute_postgres_sql(pg_conn_str, sql), #BUG input pair (F, arg) - # a.context.getTextEmbedding([vector_search_str]) #BUG input pair (F, arg) - # ) - - - # @show df - # error(888888) - - - items = nothing - if sql_result_df !== nothing - result_vec = GeneralUtils.dfToVectorDict(sql_result_df) - - # get image - for d in result_vec - image_url_json_str = d["image_url"] - image_url_json_obj = JSON.parse(image_url_json_str) - base_url = "http://192.168.88.106:8080/" - if haskey(image_url_json_obj, "bottle") - url = base_url * image_url_json_obj["bottle"] - image_data = HTTP.get(url) # vector{int} data - image_base64_string = base64encode(image_data.body) - d["image"] = image_base64_string - else - d["image"] = nothing - end - end - items = result_vec # image is added to each item - end - - thoughtdict["action_result"] = textresult - end - - return (thoughtdict=thoughtdict, result_raw=items) -end - - -""" -Generate an SQL query from a natural language search term. - -Uses an LLM to generate SQL based on the database schema relevant to the search term, -with validation and retry logic. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing: - - `find_related_tables_for_user_question`: Function to find relevant database tables - - `pg_conn_str`: PostgreSQL connection string - - `text2textInstructLLM`: LLM function for text generation -- `searchterm::String`: Natural language search term to convert to SQL - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid SQL response from the LLM - -# Returns -- `String`: A valid SQL query string - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model for SQL generation. -- Dynamically fetches only relevant table schemas for the search term. -- Validates LLM response contains required keys: "plan", "action_name", "action_input". -- `action_name` must be "RUNSQL"; `action_input` must not contain "RUNSQL". -- Strips triple backticks and extracts SQL from fenced code blocks. -- Throws on failure after `maxattempt` retries. - -# Examples -```jldoctest -julia> sql = YiemAgent.generatesql(agent, "red wine under 50") -"SELECT ... WHERE w.wine_type = 'red' AND rw.price < 50;" -``` -""" -function generatesql(a::T, searchterm::String, - ; maxattempt=10 - )::String where {T<:agent} - - 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. - - A junction table can be used to link tables together. Another use case is for filtering data. - - If you can't find a single table that can be used to answer the user's search term, try joining multiple tables to see if you can obtain the answer. - - Text information in the database usually stored in lower case. If your search returns empty, try using lower case to search. - - Overly strict condition usually yields empth result - - # situation - At each round of conversation, you will be given the following: - - user search term - - # objective - Consult the database_search_guidelines. Then find the data from a database to satisfy the user's search term. - - # your responsibility includes - Fulfill the objective. - - # you should then respond to the user with interleaving plan, action_name, action_input - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", Must be "RUNSQL" - 3) "action_input", The input to the action you are about to perform according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # you should only respond in JSON format as described below - "plan": "...", - "action_name": "...", - "action_input": "..." - - # available_actions - "RUNSQL", which you can use to execute SQL against the database. - The input must be a single SQL query to be executed against the database. - For more effective text search, it's necessary to use case-insensitivity and the ILIKE operator. - Do not wrap the SQL as it will be executed against the database directly and SQL must be ended with ';'. - """ - - # table_schema = - # """ - # create table customer ( - # customer_id uuid primary key default gen_random_uuid (), - # customer_firstname varchar(128), - # customer_lastname varchar(128), - # customer_displayname varchar(128) not null, - # customer_username varchar(128), - # customer_password varchar(128), - # customer_gender varchar(128), - # country varchar(128), - # telephone varchar(128), - # email varchar(128) not null, - # customer_birthdate varchar(128), - # 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 food ( - # food_id uuid primary key default gen_random_uuid (), - # food_name varchar(128) not null, - # country varchar(128), - # spiciness integer, - # sweetness integer, - # sourness integer, - # savoriness integer, - # bitterness integer, - # serving_temperature integer, - # image_url jsonb, - # note text, - # other_attributes jsonb, - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp, - # description text - # ); - - # create table wine ( - # wine_id uuid primary key default gen_random_uuid (), - # seo_name varchar(128) not null, - # 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 wine_food ( - # wine_id uuid references wine(wine_id), - # food_id uuid references food(food_id), - # constraint wine_food_id primary key (wine_id, food_id), - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp - # ); - - # 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 - # ); - - # CREATE TABLE retailer_food ( - # retailer_id uuid references retailer(retailer_id), - # food_id uuid references food(food_id), - # constraint retailer_food_id primary key (retailer_id, food_id), - # price NUMERIC(10, 2), - # currency varchar(3) not null, - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp - # ); - # """ - - requiredKeys = ["plan", "action_name", "action_input"] - errornote = "" - # provide similar sql only for the first attempt - # sql, distance = a.context.similarSQLVectorDB(searchterm) - - # similarSQL_ = sql !== nothing ? sql : "None" - # # if sql is really close, just use it - # if similarSQL_ != "None" && distance <= 0.1 - # return similarSQL_ - # end - - #CHANGE use find_related_tables_for_user_question and inject only related table schema instead - # of hard code table schema. CPU embedding is too slow. use embedding service on GPU. - related_tables = a.context.find_related_tables_for_user_question(searchterm) - table_schema = "" - for table in related_tables - _table_schema_str = GeneralUtils.get_db_table_schema_simple(a.context.pg_conn_str, table) - table_schema_str = sprint(show, _table_schema_str) * "\n" - table_schema = table_schema * table_schema_str - end - - context = - """ - - - $table_schema - - - """ - input = context * searchterm - - 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" => input), - ] - ), - ], - "temperature" => 0.7 - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM("random_id", msg) - - response = GeneralUtils.clean_json_response(response) - - think, response = GeneralUtils.extractthink(response) - responsedict = nothing - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict, keytype=String, sort_order=requiredKeys) - catch - println("\nERROR decisionMaker() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # remove backticks Error occurred: MethodError: no method matching occursin(::String, ::Vector{String}) - if occursin("```", responsedict["action_input"]) - sql = GeneralUtils.extract_triple_backtick_text(responsedict["action_input"])[1] - if sql[1:4] == "sql\n" - sql = sql[5:end] - end - sql = split(sql, ';') # some time there are comments in the sql - sql = sql[1] * ';' - - responsedict["action_input"] = sql - end - - toollist = ["RUNSQL"] - if responsedict["action_name"] ∉ toollist - errornote = "Your previous attempt has action_name that is not in the tool list" - println("\nERROR SQLLLM decisionMaker(). Attempt $attempt/$maxattempt. $errornote --(not qualify response)--> $(responsedict["action_name"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - for i in toollist - if occursin(i, responsedict["action_input"]) - errornote = "Your previous attempt has action_name in action_input which is not allowed" - println("\nERROR SQLLLM decisionMaker(). Attempt $attempt/$maxattempt. $errornote --(not qualify response)--> $(responsedict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - end - - # println("\nSQLLLM decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - # println("---") - - return responsedict["action_input"] - end - error("SQLLLM DecisionMaker() failed to generate a thought \n", response) -end - - -""" -# Example -```jldoctest -julia> using ChatAgent -julia> agent = YiemAgent.sommelier(...) -julia> thoughtdict = - OrderedDict{String, Any}( - "plan" => "The user is asking a very specific question about a wine (Brunello di Montalcino from Tenuta CastelGiocondo). Although the policy suggests gathering budget, wine type, and occasion, the user has provided enough specific information (name, region, producer) to attempt a direct search in the database. I will use the SEARCH_WINE_DATABASE action to check if this specific wine is in our inventory.", - "action_name" => "SEARCH_WINE_DATABASE", - "action_input" => "Brunello di Montalcino from Tenuta CastelGiocondo") -``` -julia> predefined_wine_search_sql(agent, thoughtdict["action_input"]) -""" -""" -Classify a wine search term into hard SQL conditions and vector search words. - -Uses an LLM to extract database column conditions from a natural language search term, -then classifies them into hard conditions (for SQL WHERE clauses) and vector search -entries (for approximate matching). - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing: - - `find_related_tables_for_user_question`: Function to find relevant tables - - `pg_conn_str`: PostgreSQL connection string - - `text2textInstructLLM`: LLM function for text generation -- `searchterm::String`: Natural language search term to classify - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid classification from the LLM - -# Returns -- `NamedTuple{(:hard_conditions, :vector_search), Tuple{Vector{JSON.Object{String, Any}}, Vector{JSON.Object{String, Any}}}}`: - - `hard_conditions`: Entries with standard operators (=, <>, !=, >, <, >=, <=) for SQL WHERE clauses - - `vector_search`: Entries with non-standard operators (LIKE, IN, IS NULL, etc.) for vector search - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with JSON schema response format. -- Applies fuzzy correction for "fuzzy_correction" bucket columns via `resolve_entity`. -- Uses `classify_column` to determine the column type bucket. -- Uses `harvest_entity_catalog` and `resolve_entity` (threshold=0.9) for fuzzy matching. - -# Examples -```jldoctest -julia> result = YiemAgent.wine_search_term_classification(agent, "dry red wine under 30") -(hard_conditions=[...], vector_search=[...]) -``` -""" -function wine_search_term_classification(a::T, searchterm::String, - ; maxattempt=10 - ) where {T<:agent} - - systemmsg = - """ - - # situation - At each round of conversation, you will be given the following: - - user search term - - database tables schema - - # objective - Consult the provided database schema (tables and columns), please map a user's natural-language search term to the appropriate database columns and tables—identify the relevant fields, operators, and values (e.g., for SQL filtering). - - # your responsibility includes - Fulfill the objective. - - # You must output your response as a JSON object containing a single key: "extracted_info". - The "extracted_info" key must contain an array of objects. Each object must contain: - 1) "table_name": The name of the table. - 2) "column_name": The specific column being filtered. - 3) "operator": The comparison operator (e.g., "=", ">"). - 4) "value": The value to compare against. - - If the user does not specify any filters, return an empty array for "extracted_info": {"extracted_info": []}. - - # here are some example - - 4-wheel drive car with red color that will give me fast and furious emotion. No more than 7000 USD - - - { - "extracted_info": [ - { - "table_name": "car_info", - "column_name": "drive_type", - "operator": "=", - "value": "4-wheel" - }, - { - "table_name": "car_info", - "column_name": "color", - "operator": "=", - "value": "red" - }, - { - "table_name": "car_info", - "column_name": "drive_feeling", - "operator": "=", - "value": "fast and furious" - }, - { - "table_name": "price_list", - "column_name": "price", - "operator": "<", - "value": "7000" - } - } - - """ - - - # use find_related_tables_for_user_question and inject only related table schema for a given search term - # to LLM instead of giving LLM all tables schema. - related_tables = a.context.find_related_tables_for_user_question(searchterm) - table_schema = "" - for table in related_tables - _table_schema_str = get_db_table_schema_simple_with_samples(a.context.pg_conn_str, table) - - # _table_schema_str = GeneralUtils.get_db_table_schema_simple(a.context.pg_conn_str, table) - table_schema_str = sprint(show, _table_schema_str) * "\n" - table_schema = table_schema * table_schema_str - end - - context = - """ - - - $table_schema - - - """ - input = context * searchterm - - response_format = Dict( - "type" => "json_schema", - "json_schema" => Dict( - "name" => "extracted_conditions", - "strict" => true, - "schema" => Dict( - "type" => "object", - "properties" => Dict( - "extracted_info" => Dict( - "type" => "array", - "items" => Dict( - "type" => "object", - "properties" => Dict( - "table_name" => Dict( - "type" => "string", - "description" => "The name of the database table." - ), - "column_name" => Dict( - "type" => "string", - "description" => "The name of the column to filter on." - ), - "operator" => Dict( - "type" => "string", - "enum" => ["=", "!=", ">", "<", ">=", "<=", "LIKE", "IN", "IS NULL", "IS NOT NULL"], - "description" => "The SQL comparison operator." - ), - "value" => Dict( - "type" => ["string", "null"], - "description" => "The value to compare against. Use null for IS NULL/IS NOT NULL." - ) - ), - "required" => ["table_name", "column_name", "operator", "value"], - "additionalProperties" => false - ) - ) - ), - "required" => ["extracted_info"], - "additionalProperties" => false - ) - ) -) - - 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" => input), - ] - ), - ], - "temperature" => 0.7, - "response_format"=> response_format, - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM("random_id", msg) - responsedict = JSON.parse(response) - # responsedict = nothing - # try - # responsedict = Serde.parse_yaml(response) - # catch e - # println("\nERROR YiemAgent predefined_wine_search_sql() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - # println("\n ", table_schema) - println("\n ", responsedict) - @info "before BM25 " @__LINE__ - - # to ensure user input is correct - for entry in responsedict["extracted_info"] - table_name = entry["table_name"]::String - column_name = entry["column_name"]::String - - bucket = classify_column(a.context.pg_conn_str, table_name, column_name) - - if bucket == "fuzzy_correction" - words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, table_name, column_name) - resolved_word = GeneralUtils.resolve_entity(entry["value"], words_catalog; threshold=0.9) - entry["value"] = resolved_word - end - end - - # filter for column that will be used for hard condition (SQL where clause) - # column with non-standard operator will be used in vector search - vector_search_words = "" - hard_operators = ["=","<>","!=",">","<",">=","<=","!<","!>","<=>"] - - # Build new list of hard condition entries - hard_conditions = JSON.Object{String, Any}[] - vector_search = JSON.Object{String, Any}[] - for entry in responsedict["extracted_info"] - if entry["operator"] ∈ hard_operators - push!(hard_conditions, entry) - else - push!(vector_search, entry) - end - end - responsedict = hard_conditions - - println("") - @show responsedict - @info "predefined_wine_search_sql() " @__LINE__ - - return (hard_conditions=hard_conditions, vector_search=vector_search) - end - error("SQLLLM DecisionMaker() failed to generate a thought \n", response) -end - -""" -Build a SQL query from pre-classified wine search conditions. - -Constructs a JOIN query across `wine`, `retailer_wine`, and `retailer` tables with -dynamic WHERE clauses based on the provided conditions. - -# Arguments -- `conditions::Vector{JSON.Object{String, Any}}`: Vector of condition objects, each containing: - - `table_name`: One of "wine", "retailer_wine" (other tables are skipped) - - `column_name`: The column to filter on - - `operator`: SQL comparison operator (=, <>, !=, >, <, >=, <=) - - `value`: The value to compare against (number or string) - -# Returns -- `String`: A complete SQL query with WHERE clause - -# Notes -- Supports table aliases: "wine" → "w", "retailer_wine" → "rw" -- Automatically handles numeric vs string value types in SQL formatting -- Strings are single-quote escaped (replaces "'" with "''") -- Returns a query with no WHERE clause if conditions vector is empty - -# Examples -```jldoctest -julia> cond = [JSON.Object{String, Any}("table_name"=>"wine", "column_name"=>"wine_type", "operator"=>"=", "value"=>"red")]; -julia> YiemAgent.predefined_wine_search_sql(cond) -"SELECT ... FROM wine AS w JOIN ... WHERE w.wine_type = 'red';" -``` -""" -function predefined_wine_search_sql(conditions::Vector{JSON.Object{String, Any}})::String - # 1. Base SQL structure - base_query = -""" -SELECT - w.winery, - w.wine_name, - w.wine_id, - w.vintage, - w.region, - w.country, - w.wine_type, - w.grape, - w.serving_temperature, - w.sweetness, - w.intensity, - w.tannin, - w.acidity, - w.tasting_notes, - rw.price, - rw.currency, - w.image_url, - r.retailer_name, - rw.retailer_id -FROM wine AS w -JOIN retailer_wine AS rw ON w.wine_id = rw.wine_id -JOIN retailer AS r ON rw.retailer_id = r.retailer_id -""" - - # 2. Dynamic WHERE Clause Builder - where_clauses = String[] - - # Iterate over each condition object in the array - for cond in conditions - table_name = String(cond["table_name"]) - column_name = String(cond["column_name"]) - op = String(cond["operator"]) - raw_val = cond["value"] - - # Determine table alias - alias = if table_name == "wine" - "w" - elseif table_name == "retailer_wine" - "rw" - else - continue - end - - # --- Value Type Handling --- - final_val = raw_val - - if op in ("=", "<", ">", "<=", ">=") - str_val = string(raw_val) - num_val = tryparse(Float64, str_val) - - if !isnothing(num_val) - final_val = isinteger(num_val) ? round(Int, num_val) : num_val - end - end - - # --- SQL Formatting --- - if isa(final_val, Number) - clause = "$(alias).$(column_name) $(op) $(final_val)" - else - escaped_val = replace(string(final_val), "'" => "''") - clause = "$(alias).$(column_name) $(op) '$(escaped_val)'" - end - - push!(where_clauses, clause) - end - - # 3. Assemble Final Query - where_sql = isempty(where_clauses) ? "" : "WHERE " * join(where_clauses, " AND ") - - return string(base_query, where_sql, ";") -end - -""" -Execute a SQL query against the database and return formatted results. - -Adds `ORDER BY RANDOM() LIMIT 2` for non-LIMITed queries, removes `DISTINCT`, and returns -either a formatted string result or the DataFrame. - -# Arguments -- `executeSQL::Function`: A function that executes SQL and returns results (e.g., PostgreSQL connection) -- `sql::T`: The SQL query string to execute (subtype of `AbstractString`) - -# Returns -- `NamedTuple{(:result_str, :result_raw, :success, :errormsg)}`: A named tuple with: - - `result_str::Union{String, Nothing}`: Formatted string representation of results - - `result_raw::Union{DataFrame, Nothing}`: The DataFrame result, or `nothing` - - `success::Bool`: Whether the query executed successfully - - `errormsg::Union{String, Nothing}`: Error message if failed, or `nothing` - -# Notes -- Removes `DISTINCT` keyword before execution (incompatible with `RANDOM()`) -- Appends `ORDER BY RANDOM() LIMIT 2` if query doesn't have `LIMIT` and ends with `;` -- Returns "No records found" message if zero rows -- Returns column count warning if more than 30 columns -- Randomly samples 2 rows if result has more than 2 rows - -# Examples -```jldoctest -julia> result = YiemAgent.SQLexecution(execute_fn, "SELECT * FROM wine;") -(result_str="...", result_raw=DataFrame(...), success=true, errormsg=nothing) -``` -""" -function SQLexecution(executeSQL::Function, sql::T - )::NamedTuple where {T<:AbstractString} - - try - # add LIMIT to the SQL to prevent loading large data - sql = strip(sql) - - # remove DISTINCT keyword because it is incompatible with RANDOM() - sql = replace(sql, "DISTINCT" => "") - - if sql[end] == ';' - if !occursin("LIMIT", sql) - sql = sql[1:end-1] * " ORDER BY RANDOM() LIMIT 2;" - end - else - sql = sql * ";" - end - result = executeSQL(sql) - df = DataFrame(result) - tablesize = size(df) - row, column = tablesize - if row == 0 - return (result_str="No records found. Try loosening your search criteria.", result_raw=nothing, success=true, errormsg=nothing) - elseif column > 30 - return (result_str="There are more than 30 columns. Please be more specific.", result_raw=df, success=true, errormsg=nothing) - else - df1 = - if row > 2 - # ramdom row to pick - df[sample(1:nrow(df), 2, replace=false), :] # random select 2 rows from df - else - df - end - result = GeneralUtils.dfToString(df1) - # println("\n~~~ SQLexecution() result: ", @__FILE__, " ", @__LINE__) - # println(sql) - # println(df1) - # println("\n") - return (result_str=result, result_raw=df1, success=true, errormsg=nothing) - end - catch e - io = IOBuffer() - showerror(io, e) - errorMsg = String(take!(io)) - st = sprint((io, v) -> show(io, "text/plain", v), stacktrace(catch_backtrace())) - println(errorMsg) - return (result_str=nothing, result_raw=nothing, success=false, errormsg=errorMsg) - end -end - -""" -DEPRECATED: Search for wines in stock (legacy implementation). - -Use `search_wine_database!` instead. This function uses the older approach with -`extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) -- `thoughtdict::AbstractDict`: Dictionary containing `action_input` (search query) - -# Keyword Arguments -- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query - -# Returns -- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}` - -# Notes -- DEPRECATED: Use `search_wine_database!` for the current implementation. -- Calls `extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. -""" -function DEPRECIATED_search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - # XXX - predefined_wine_search_sql(a, thoughtdict["action_input"]) - - println("\ncheckinventory order: $(thoughtdict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - wineattributes_1 = extractWineAttributes_1(a, thoughtdict["action_input"]) - wineattributes_2 = extractWineAttributes_2(a, thoughtdict["action_input"]) - - retrieve_attributes = ["winery", "wine_name", "wine_id", "vintage", "region", "country", "wine_type", "grape", "serving_temperature", "sweetness", "intensity", "tannin", "acidity", "tasting_notes", "price", "currency", "image_url", "retailer_name", "retailer_id"] - _inventoryquery = "$(thoughtdict["action_input"]), $wineattributes_1, $wineattributes_2, retailer_name: $(a.retailername), retailerid: $(a.retailerid)" - inventoryquery = "Retrieves $retrieve_attributes of wines that match the following criteria - {$_inventoryquery}" - println("\ncheckinventory input: $inventoryquery ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - - if useSQLLLM - # add suppport for similarSQLVectorDB - textresult, result_raw = SQLLLM.query( - inventoryquery, - a.context.executeSQL, - a.context.text2textInstructLLM; - insertSQLVectorDB=a.context.insertSQLVectorDB, - similarSQLVectorDB=a.context.similarSQLVectorDB, - llmFormatName="qwen3") - thoughtdict["action_result"] = textresult - else - - # direct query with possible sql instead of SQLLLM. - sql = generatesql(a, inventoryquery) - println("\nSQL: $sql ", @__FILE__, ":", @__LINE__, " $(Dates.now()) \n") - textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql) - - items = nothing - if sql_result_df !== nothing - result_vec = GeneralUtils.dfToVectorDict(sql_result_df) - - # get image - for d in result_vec - image_url_json_str = d["image_url"] - image_url_json_obj = JSON.parse(image_url_json_str) - base_url = "http://192.168.88.106:8080/" - if haskey(image_url_json_obj, "bottle") - url = base_url * image_url_json_obj["bottle"] - image_data = HTTP.get(url) # vector{int} data - image_base64_string = base64encode(image_data.body) - d["image"] = image_base64_string - else - d["image"] = nothing - end - end - items = result_vec # image is added to each item - end - - thoughtdict["action_result"] = textresult - end - - return (thoughtdict=thoughtdict, result_raw=items) -end - -""" -Extract wine attributes from a user's search query. - -Uses an LLM to parse natural language input into structured wine attributes including -name, winery, vintage, country, type, grape, price range, occasion, and food pairing. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with context containing: - - `text2textInstructLLM`: LLM function for text generation - - `pg_conn_str`: PostgreSQL connection string - - `id`: Agent identifier -- `input::T2`: User's search query string (subtype of `AbstractString`) - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid response from the LLM - -# Returns -- `String`: Comma-separated list of extracted attributes in the format `"key: value, key: value"` - Attributes with "N/A", empty, or "none" values are excluded. - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. -- Extracts: wine_name, winery, vintage, country, wine_type, grape_varietal, tasting_notes, - wine_price_min, wine_price_max, occasion, food_to_be_paired_with_wine -- Validates response contains all required keys via `checkAgentResponse_JSON`. -- Applies fuzzy entity resolution via `harvest_entity_catalog` and `resolve_entity` (threshold=0.9). -- Strips "(some comment)" patterns from values. -- Removes keys: thought, tasting_notes, occasion, food_to_be_paired_with_wine, vintage from final output. - -# Examples -```jldoctest -julia> YiemAgent.extractWineAttributes_1(agent, "red wine from Napa under 50") -"wine_name:N/A, winery:N/A, vintage:N/A, country:United States, wine_type:red, grape_varietal:N/A, wine_price_min:0, wine_price_max:50" -``` -""" -function extractWineAttributes_1(a::T1, input::T2; maxattempt=10 - )::String where {T1<:agent, T2<:AbstractString} - - systemmsg = - """ - - At each round of conversation, the user provides the following: - - The query: the query provided by the user. - - - Extract information from the user's query as much as possible according to wine attributes extraction guidelines to fill out user's preference form. - - - Fulfill the objective. - - - - If specific information required in the preference form is not available in the query or there isn't any, mark with "N/A" to indicate this. - Additionally, words like 'any' or 'unlimited' mean no information is available. - - Do not generate other comments. - - - wine_name: name of the wine - winery: name of the winery - vintage: the year of the wine - country: a country where wine is produced. Can be "Austria", "Australia", "France", "Germany", "Italy", "Portugal", "Spain", "United States". Use "or" if there are multiple countries. - wine_type: can be one of: "red", "white", "sparkling", "rose", "dessert" or "fortified" - grape_varietal: the name of the primary grape used to make the wine - tasting_notes: a word describe the wine's flavor, such as "butter", "oak", "fruity", "raspberry", "earthy", "floral", etc - wine_price_min: minimum price range of wine. Example: For wine price 20, wine_price_min will be 0. For wine price 10 to 100, wine_price_min will be 10. - wine_price_max: maximum price range of wine. Example: For wine price 20, wine_price_max will be 20. For wine price 10 to 100, wine_price_max will be 100. - occasion: the occasion the user is having the wine for - food_to_be_paired_with_wine: food that the user will be served with the wine such as poultry, fish, steak, etc - _keyword suffice is the related keyword that appears in user's query. - - - "wine_name": "...", - "winery": "...", - "vintage": "...", - "country": "...", - "wine_type": "...", - "grape_varietal": "...", - "tasting_notes": "...", - "wine_price_min": "...", - "wine_price_max": "...", - "occasion": "...", - "food_to_be_paired_with_wine": "..." - - - User's query: red, Chenin Blanc, Riesling, 20 USD from Tuscany, Italy or Napa Valley, USA - "wine_name": "N/A", - "winery": "N/A", - "vintage": "N/A", - "country": "Italy or United States", - "wine_type": "red or white", - "grape_varietal": "Chenin Blanc or Riesling", - "tasting_notes": "citrus", - "wine_price_min": "0", - "wine_price_max": "20", - "occasion": "N/A", - "food_to_be_paired_with_wine": "N/A" - - User's query: Domaine du Collier Saumur Blanc 2019, France, white, Merlot - "wine_name": "Saumur Blanc", - "winery": "Domaine du Collier", - "vintage": "2019", - "country": "France", - "wine_type": "white", - "grape_varietal": "Merlot", - "tasting_notes": "N/A", - "wine_price_min": "N/A", - "wine_price_max": "N/A", - "occasion": "N/A", - "food_to_be_paired_with_wine": "N/A" - - """ - requiredKeys = ["wine_name", "winery", "vintage", "country", "wine_type", "grape_varietal", "tasting_notes", "wine_price_min", "wine_price_max", "occasion", "food_to_be_paired_with_wine"] - errornote = "" - context = - """ - - $errornote - - """ - - input = context * input - - 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" => input), - ] - ), - ], - "temperature" => 0.7 - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - responsedict = nothing - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent extractWineAttributes_1() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent extractWineAttributes_1() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - removekeys = ["thought", "tasting_notes", "occasion", "food_to_be_paired_with_wine", "vintage"] - for i in removekeys - delete!(responsedict, i) - end - # remove (some text) - for (k, v) in responsedict - _v = replace(v, r"\(.*?\)" => "") - responsedict[k] = _v - end - - @info "YiemAgent extractWineAttributes_1() " @__LINE__ - @show responsedict - @info "---\n" @__LINE__ - - # check each attributes against each column in a database table with BM25 - for (k, v) in responsedict - if k ∉ ["wine_price_min", "wine_price_max"] - words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, "wine", k) - resolved_word = GeneralUtils.resolve_entity(v, words_catalog; threshold=0.9) - responsedict[k] = resolved_word - end - end - - result = "" - for (k, v) in responsedict - # some time LLM generate text with "(some comment)". this line removes it - if !occursin("N/A", v) && v != "" && !occursin("none", v) && !occursin("None", v) - result *= "$k: $v, " - end - end - - result = result[1:end-2] # remove the ending ", " - - @info "YiemAgent extractWineAttributes_1() " @__LINE__ - @show result - @info "---\n" @__LINE__ - - return result - end - error("extractWineAttributes_1() failed to get a response") -end - -""" -Extract wine intensity, sweetness, tannin, and acidity attributes from a query. - -Uses an LLM with a conversion table to map descriptive words (e.g., "medium-bodied", "low acidity") -to integer ranges on a 1-5 scale. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with context containing: - - `text2textInstructLLM`: LLM function for text generation - - `id`: Agent identifier -- `input::T2`: User's query string containing descriptive wine preferences - (subtype of `AbstractString`) - -# Returns -- `String`: Comma-separated list of extracted attributes in the format - `"key: value, key: value"`. Only includes numeric values or non-N/A strings. - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. -- Extracts: sweetness, acidity, tannin, intensity (each with min/max values). -- Applies `remove_french_accents` to the LLM response. -- Extracts thinking via `extractthink` before JSON parsing. -- Validates response contains all required keys via `checkAgentResponse_JSON`. -- Removes keyword fields (sweetness_keyword, acidity_keyword, etc.) from final output. -- Only includes values that are numbers or non-"N/A" strings. - -# TODO -- "French dry white wines with medium bod" — the LLM does not recognize sweetness. Use LLM self-questioning to solve. -- French Syrah, Viognier, under 100 — LLM extracts intensity of 3-5. Investigate why. - -# Examples -```jldoctest -julia> YiemAgent.extractWineAttributes_2(agent, "medium-bodied, low acidity, medium tannin") -"acidity_min:1, acidity_max:2, tannin_min:3, tannin_max:4, intensity_min:3, intensity_max:4" -``` -""" -function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<:AbstractString} - - conversiontable = - """ - - Intensity level: - 1 to 2: May correspond to "light-bodied" or a similar description. - 2 to 3: May correspond to "med light bodied", "medium light" or a similar description. - 3 to 4: May correspond to "medium bodied" or a similar description. - 4 to 5: May correspond to "med full bodied", "medium full" or a similar description. - 4 to 5: May correspond to "full bodied" or a similar description. - Sweetness level: - 1 to 2: May correspond to "dry", "no sweet" or a similar description. - 2 to 3: May correspond to "off dry", "less sweet" or a similar description. - 3 to 4: May correspond to "semi sweet" or a similar description. - 4 to 5: May correspond to "sweet" or a similar description. - 4 to 5: May correspond to "very sweet" or a similar description. - Tannin level: - 1 to 2: May correspond to "low tannin" or a similar description. - 2 to 3: May correspond to "semi low tannin" or a similar description. - 3 to 4: May correspond to "medium tannin" or a similar description. - 4 to 5: May correspond to "semi high tannin" or a similar description. - 4 to 5: May correspond to "high tannin" or a similar description. - Acidity level: - 1 to 2: May correspond to "low acidity" or a similar description. - 2 to 3: May correspond to "semi low acidity" or a similar description. - 3 to 4: May correspond to "medium acidity" or a similar description. - 4 to 5: May correspond to "semi high acidity" or a similar description. - 4 to 5: May correspond to "high acidity" or a similar description. - - """ - - systemmsg = - """ - - At each round of conversation, you will be given the following information: - conversion_table: a conversion table that maps descriptive words to their corresponding integer levels - query: the words from the user's query that describe their preferences - - - Fill out the user's preference form based on the corresponding words from the user's query according to the guidelines. - - - Fulfill the objective - - - - The preference form requires sweetness, acidity, tannin, intensity infomation - - If specific information required in the preference form is not available in the query or there isn't any, mark with 'N/A' to indicate this. - Additionally, words like 'any' or 'unlimited' mean no information is available. - - Use the conversion table to convert the descriptive word level of sweetness, intensity, tannin, and acidity into a corresponding integer. - - Do not generate other comments. - - - sweetness_keyword: The exact keywords in the user's query describing the sweetness level of the wine. - sweetness: ( S ), where ( S ) represents integers indicating the range of sweetness levels. Example: 1-2 - acidity_keyword: The exact keywords in the user's query describing the acidity level of the wine. - acidity: ( A ), where ( A ) represents integers indicating the range of acidity level. Example: 3-5 - tannin_keyword: The exact keywords in the user's query describing the tannin level of the wine. - tannin: ( T ), where ( T ) represents integers indicating the range of tannin level. Example: 1-3 - intensity_keyword: The exact keywords in the user's query describing the intensity level of the wine. - intensity: ( I ), where ( I ) represents integers indicating the range of intensity level. Example: 2-4 - - - "sweetness_keyword": "...", - "sweetness_min": "...", - "sweetness_max": "...", - "acidity_keyword": "...", - "acidity_min": "...", - "acidity_max": "...", - "tannin_keyword": "...", - "tannin_min": "...", - "tannin_max": "...", - "intensity_keyword": "...", - "intensity_min": "...", - "intensity_max": "..." - - - User's query: I want a wine with a medium-bodied, low acidity, medium tannin. - "sweetness_keyword": "N/A", - "sweetness_min": "N/A", - "sweetness_max": "N/A", - "acidity_keyword": "low acidity", - "acidity_min": 1, - "acidity_max": 2, - "tannin_keyword": "medium tannin", - "tannin_min": 3, - "tannin_max": 4, - "intensity_keyword": "medium-bodied", - "intensity_min": 3, - "intensity_max": 4 - - User's query: German red wine, under 100, pairs with spicy food. - "sweetness_keyword": "N/A", - "sweetness_min": "N/A", - "sweetness_max": "N/A", - "acidity_keyword": "N/A", - "acidity_min": "N/A", - "acidity_max": "N/A", - "tannin_keyword": "N/A", - "tannin_min": "N/A", - "tannin_max": "N/A", - "intensity_keyword": "N/A", - "intensity_min": "N/A", - "intensity_max": "N/A" - - """ - requiredKeys = ["sweetness_keyword", "sweetness_min", "sweetness_max", - "acidity_keyword", "acidity_min", "acidity_max", - "tannin_keyword", "tannin_min", "tannin_max", - "intensity_keyword", "intensity_min", "intensity_max"] - errornote = "" - context = - """ - - $conversiontable - $errornote - - """ - - input = context * input - - 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" => input), - ] - ), - ], - "temperature" => 0.7 - ) - - for attempt in 1:10 - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - responsedict = nothing - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent extractWineAttributes_2() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent extractWineAttributes_2() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # delete some key words from responsedict - for (k, v) in responsedict - if k ∈ ["sweetness_keyword", "acidity_keyword", "tannin_keyword", "intensity_keyword"] - delete!(responsedict, k) - end - end - - # get result in String. Reject "N/A" value - result = "" - for (k, v) in responsedict - if typeof(v) <: Number - result *= "$k: $v, " - elseif typeof(v) == String && !occursin("N/A", v) - result *= "$k: $v, " - end - end - result = result[1:end-2] # remove the ending ", " - - @info "YiemAgent extractWineAttributes_2() " @__LINE__ - @show result - @info "---\n" @__LINE__ - - return result - end - error("extractWineAttributes_2() failed to get a response") -end - - - - - - -""" -Get a simplified DDL schema for a PostgreSQL table with sample values. - -Establishes a new connection and delegates to the connection-based overload. - -# Arguments -- `pg_conn_str::String`: PostgreSQL connection string -- `table_name::String`: Name of the table to get schema for - -# Keyword Arguments -- `schema_name::String="public"`: PostgreSQL schema name - -# Returns -- `String`: Formatted DDL schema string with sample values - -# Notes -- Delegates to `get_db_table_schema_simple_with_samples(conn, table_name, ...)` after creating - a new `LibPQ.Connection`. - -# Examples -```jldoctest -julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn_str, "wine") -"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" -``` -""" -function get_db_table_schema_simple_with_samples(pg_conn_str::String, table_name::String; - schema_name::String="public")::String - conn = LibPQ.Connection(pg_conn_str) - return get_db_table_schema_simple_with_samples(conn, table_name; schema_name=schema_name) -end - -""" -Get a simplified DDL schema for a PostgreSQL table with sample values. - -Queries the PostgreSQL catalog for column metadata, fetches sample values, and builds -a DDL string with inline comments for each column. - -# Arguments -- `conn`: An active PostgreSQL connection (e.g., `LibPQ.Connection`) -- `table_name::String`: Name of the table to get schema for - -# Keyword Arguments -- `schema_name::String="public"`: PostgreSQL schema name -- `sample_count::Int=3`: Number of non-null sample values to fetch per column - -# Returns -- `String`: Formatted DDL schema string in the style of `CREATE TABLE` statements - with sample values as inline comments - -# Notes -- Queries `pg_attribute`, `pg_class`, `pg_namespace`, `pg_attrdef`, and `pg_constraint` - for column metadata, defaults, and constraints. -- Fetches up to `sample_count` non-null values per column via `json_agg`. -- Throws an error if the table is not found. - -# Examples -```jldoctest -julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn, "wine") -"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" -``` -""" -function get_db_table_schema_simple_with_samples(conn, table_name::String; schema_name::String="public", sample_count::Int=3)::String - # 1. SQL query for catalog metadata - meta_sql = """ - SELECT - a.attname AS column_name, - format_type(a.atttypid, a.atttypmod) AS data_type, - pg_get_expr(def.adbin, def.adrelid) AS default_value, - COALESCE( - (SELECT pg_get_constraintdef(p.oid) - FROM pg_catalog.pg_constraint p - WHERE p.conrelid = c.oid AND a.attnum = ANY(p.conkey) - LIMIT 1), '' - ) AS constraint_definition - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON a.attrelid = c.oid - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = c.oid AND def.adnum = a.attnum - WHERE c.relname = \$1 - AND n.nspname = \$2 - AND a.attnum > 0 - AND NOT a.attisdropped - ORDER BY a.attnum; - """ - - meta_res = DataFrame(execute(conn, meta_sql, [table_name, schema_name])) - - if nrow(meta_res) == 0 - error("Table '$schema_name.$table_name' not found.") - end - - # 2. Build single dynamic query to fetch non-null samples for all columns - sample_selects = String[] - for row in eachrow(meta_res) - c_name = row.column_name - push!(sample_selects, """ - (SELECT json_agg(s."$c_name") - FROM ( - SELECT "$c_name" - FROM "$schema_name"."$table_name" - WHERE "$c_name" IS NOT NULL - LIMIT $sample_count - ) s - ) AS "$c_name" - """) - end - - sample_sql = "SELECT " * join(sample_selects, ",\n ") * ";" - sample_df = DataFrame(execute(conn, sample_sql)) - - # 3. Build DDL definitions with inline sample comments - ddl_lines = String[] - constraints = String[] - - for row in eachrow(meta_res) - col_name = row.column_name - data_type = row.data_type - default_val = ismissing(row.default_value) ? "" : " DEFAULT " * row.default_value - - col_def = " \"$col_name\" $data_type$default_val" - - # Fetch sample data for this column from the single-row sample DataFrame - samples_comment = "" - if nrow(sample_df) > 0 - raw_samples = sample_df[1, Symbol(col_name)] - samples_str = ismissing(raw_samples) || isnothing(raw_samples) ? "[]" : string(raw_samples) - samples_comment = " -- Samples: $samples_str" - end - - push!(ddl_lines, col_def * samples_comment) - - # Handle table-level constraints - con_def = ismissing(row.constraint_definition) ? "" : row.constraint_definition - if !isempty(con_def) && !(con_def in constraints) - push!(constraints, " " * con_def) - end - end - - all_definitions = vcat(ddl_lines, constraints) - body = join(all_definitions, ",\n") - - return "CREATE TABLE \"$schema_name\".\"$table_name\" (\n$body\n);" -end - - - -function classify_column(pg_conn_str::String, table_name::String, column_name::String; - sample_size::Integer=1000) - conn = LibPQ.Connection(pg_conn_str) - return classify_column(conn, table_name, column_name; sample_size=sample_size) -end - - -function classify_column(conn::LibPQ.Connection, table_name::String, column_name::String; sample_size::Int=1000) - # 1. Fetch BOTH data_type and udt_name (User Defined Type name) - meta_query = """ - SELECT data_type, udt_name - FROM information_schema.columns - WHERE table_name = lower('$(table_name)') - AND column_name = lower('$(column_name)'); - """ - - pg_type = "unknown" - udt_name = "unknown" - - try - df = DataFrame(LibPQ.execute(conn, meta_query)) - if !isempty(df) - pg_type = df[1, :data_type] - udt_name = df[1, :udt_name] - end - catch e - @error "Failed to fetch metadata for $table_name.$column_name" exception=e - return "error" - end - - # 2. FAST-TRACK: Check for pgvector FIRST - # pgvector registers as "USER-DEFINED" in data_type, but "vector" in udt_name - if udt_name == "vector" - return "semantic_search" - end - - # 3. FAST-TRACK: Hard rules for standard non-text Postgres types - if pg_type in ["integer", "bigint", "smallint", "numeric", "real", - "double precision", "boolean", "date", - "timestamp without time zone", "timestamp with time zone", "uuid"] - return "exact_or_range" - end - - # 4. SAMPLE: Get text statistics for remaining text columns - stats_query = """ - SELECT - COUNT(*)::int AS total_count, - COUNT(DISTINCT $(column_name)::text)::int AS unique_count, - COALESCE(AVG(LENGTH($(column_name)::text)), 0)::float AS avg_len, - COALESCE(STDDEV(LENGTH($(column_name)::text)), 0)::float AS std_len - FROM ( - SELECT $(column_name) - FROM $(table_name) - WHERE $(column_name) IS NOT NULL - LIMIT $sample_size - ) AS sampled_data; - """ - - try - df = DataFrame(LibPQ.execute(conn, stats_query)) - if isempty(df) || df[1, :total_count] == 0 - return "unknown" - end - - total = df[1, :total_count] - unique = df[1, :unique_count] - avg_len = df[1, :avg_len] - std_len = df[1, :std_len] - ratio = unique / total - - # 5. HEURISTICS: Route the column_name to the correct text bucket - return classify_text_column(unique, ratio, avg_len, std_len) - - catch e - @warn "Failed to sample column_name $table_name.$column_name" exception=e - return "unknown" - end -end - -# The Decision Tree for Text Columns (Unchanged, but kept for completeness) -function classify_text_column(unique_count::Integer, ratio::Float64, avg_len::Float64, std_len::Float64) - if avg_len > 60 && std_len > 25 - return "full_text_search" - end - if ratio > 0.90 && avg_len < 40 - return "exact_or_regex" - end - if unique_count <= 100 - return "fuzzy_correction" - end - if ratio > 0.10 && avg_len < 35 - return "fuzzy_correction" - end - if avg_len < 60 - return "fuzzy_correction" - end - return "full_text_search" -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module llmfunction \ No newline at end of file -- 2.52.0 From 7ffb720f86ca7c740d11ca68e84a52bdda44ce4c Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 20 Aug 2026 09:58:55 +0700 Subject: [PATCH 093/112] update mcp definition example --- Manifest.toml | 2 +- Project.toml | 4 - etc.jl | 46 ++++- etc.md | 3 + src/YiemAgent.jl | 106 ++++------- src/agentCore.jl | 89 ++++++++- src/toolRegistry.jl | 210 +++++++++++++++++---- src/tools/getTime.jl | 83 --------- src/tools/getWeather.jl | 48 ----- src/tools/searchWine.jl | 397 ---------------------------------------- src/tools/writeTool.jl | 276 ---------------------------- 11 files changed, 346 insertions(+), 918 deletions(-) delete mode 100644 src/tools/getTime.jl delete mode 100644 src/tools/getWeather.jl delete mode 100644 src/tools/searchWine.jl delete mode 100644 src/tools/writeTool.jl diff --git a/Manifest.toml b/Manifest.toml index 0bbbd2e..dbec9cc 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "aa163e2bf572632825162936e107be18384fd40f" +project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] diff --git a/Project.toml b/Project.toml index 7fea2af..31ce449 100644 --- a/Project.toml +++ b/Project.toml @@ -12,13 +12,11 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241" LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1" NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a" PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" -SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" @@ -31,7 +29,5 @@ DataFrames = "1.7.0" GeneralUtils = "0.5.10" HTTP = "2.4.0" JSON = "1.6.1" -LLMMCTS = "0.1.5" NATS = "0.1.0" -SQLLLM = "0.2.8" msghandler = "1.2.1" diff --git a/etc.jl b/etc.jl index be4b0e2..2a3a008 100644 --- a/etc.jl +++ b/etc.jl @@ -1,5 +1,41 @@ -check my understand: -1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and -it will be the latest message in agent._state.messages. then _agentLoop() can pick it as -the output to outputChannel -2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall \ No newline at end of file +# "tools/list" input: +mcpServer("tools/list") +# sending out payload before smart packed by msghandler +Dict( + "method"=> "tools/list" +) +# expected return after smart unpacked by msghandler +Dict("tools" => [ + Dict("toolName" => "getWeather", + "title" => "Weather Lookup", + "description" => "Fetch current weather for a city.", + "inputSchema" => Dict("type"=>"object", + "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), + "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), + "required" => ["city"]) + ) +]) + +# "tools/call" input: +mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) +# sending out payload before smart packed by msghandler +Dict( + "method"=> "tools/call", + "tools"=> Dict("toolName"=>"getWeather", "arguments"=>Dict("city"=>"Tokyo", "units"=>"celsius")) +) +# expected return after smart unpacked by msghandler +Dict( + "toolName"=>"getWeather", + "content": [{"type": "text", "text": "Weather in Tokyo: Sunny, 22°C"}], + "isError": false +) + +# If an error occurs, mcpServer returns after smart unpacked by msghandler: +Dict( + "toolName"=>"getWeather", + "content": [], + "error": Dict("code"=>1, "message"=>"City not found"), + "isError": true +) + + diff --git a/etc.md b/etc.md index 4664add..9b55263 100644 --- a/etc.md +++ b/etc.md @@ -6,3 +6,6 @@ check my understanding Is my understanding correct? +the user can provide NATS connection to MCP server by adding agent.mcpserver (a callable struct) for communication with MCP server just like agent.llmCall (also a callable struct). I think communicating with MCP server is just send/receive JSON text right? +Moreover, for simplicity I want to all tools into an MCP server so an agent can be instantiated with only listTools() in tool store then populate tools from MCP server later. +what do you think? \ No newline at end of file diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 599b859..6d40a85 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -1,66 +1,40 @@ -module YiemAgent - - export register_all_tools - - """ Order by dependencies of each file. The 1st included file must not depend on any other - files and each file can only depend on the file included before it. - """ - - include("type.jl") - using .type - - include("utils.jl") - using .utils - - include("tools/getWeather.jl") - include("tools/getTime.jl") - include("tools/searchWine.jl") - include("tools/writeTool.jl") - - include("toolRegistry.jl") - using .toolRegistry - - 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 - end - - # include("llmfunction.jl") - # using .llmfunction - - include("agentCore.jl") - using .agentCore - - include("api.jl") - using .api - - -# ---------------------------------------------- 100 --------------------------------------------- # - - - - - - - - - - - - - - - - - - - - - - - -end # module YiemAgent_v1 +module YiemAgent + + export register_all_tools + + """ Order by dependencies of each file. The 1st included file must not depend on any other + files and each file can only depend on the file included before it. + """ + + include("type.jl") + using .type + + include("utils.jl") + using .utils + + include("toolRegistry.jl") + using .toolRegistry + + function register_all_tools(store::toolRegistry.toolStore, mcpserver=nothing) + # Only register listTools — all other tools come from MCP server at runtime + registerTool(store, listTool(store, mcpserver)) + return store.tools + end + + # include("llmfunction.jl") + # using .llmfunction + + include("agentCore.jl") + using .agentCore + + include("api.jl") + using .api + + +# ---------------------------------------------- 100 --------------------------------------------- # + + + + + +end # module YiemAgent_v1 diff --git a/src/agentCore.jl b/src/agentCore.jl index e02ce2d..f0d2e14 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -9,9 +9,9 @@ using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serializ using GeneralUtils using ..type, ..utils, ..toolRegistry -function register_all_tools(store::toolRegistry.toolStore) +function register_all_tools(store::toolRegistry.toolStore, mcpServer=nothing) # Call parent module's version which has access to tool functions - parentmodule(@__MODULE__).register_all_tools(store) + parentmodule(@__MODULE__).register_all_tools(store, mcpServer) end # ---------------------------------------------- 100 --------------------------------------------- # @@ -49,7 +49,83 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Each block has a type — "text", "thinking", or "toolCall". # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). llmCall - + + # Callable struct for MCP server communication. + # Called as: mcpServer("tools/list") → returns parsed JSON dict of available tools + # mcpServer("tools/call", toolName, arguments) → returns tool result as parsed JSON dict + # + # # Example (weather tool) + # # User provides a callable struct + # struct MyMCPClient + # natsConn::NATS.Connection + # topic::String + # senderID::String + # fileserver_url::String + # end + # + # function (c::MyMCPClient)(method::String) + # payload = Dict("method"=> method) + # payloads = [("method", payload, "dictionary")] + # _, msg_envelope_json_str = msghandler.smartpack( + # c.topic, payloads; sender_id=c.senderID, + # msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url) + # reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) + # incoming_env = msghandler.smartunpack(String(reply.payload)) + # return incoming_env["payloads"][1][2] + # end + # + # function (c::MyMCPClient)(method::String, toolName::String, arguments::Dict{String,Any}) + # payload = Dict("method"=> method, "toolName"=> toolName, "arguments"=>arguments) + # payloads = [("method", payload, "dictionary"),] + # _, msg_envelope_json_str = msghandler.smartpack( + # c.topic, payloads; sender_id=c.senderID, + # msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url) + # reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) + # incoming_env = msghandler.smartunpack(String(reply.payload)) + # return incoming_env["payloads"][1][2] + # end + # + # # "tools/list" input: + # mcpServer("tools/list") + # # sending out payload before smart packed by msghandler: + # Dict("method"=> "tools/list") + # # expected return after smart unpacked by msghandler: + # Dict("tools" => [ + # Dict( + # "toolName" => "getWeather", + # "title" => "Weather Lookup", + # "description" => "Fetch current weather for a city.", + # "inputSchema" => Dict("type"=>"object", + # "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), + # "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), + # "required" => ["city"]) + # ) + # ]) + # + # # "tools/call" input: + # mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) + # # sending out payload before smart packed by msghandler: + # Dict( + # "method"=> "tools/call", + # "toolName"=>"getWeather", + # "arguments"=>Dict("city"=>"Tokyo", "units"=>"celsius") + # ) + # # expected return after smart unpacked by msghandler (success): + # Dict( + # "toolName"=> "getWeather", + # "content" => [{"type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C"}], + # "error"=> "", + # "isError" => false + # ) + # # expected return after smart unpacked by msghandler (error): + # Dict( + # "toolName"=> "getWeather", + # "content" => [], + # "error" => Dict("code"=>1, "message"=>"City not found"), + # "isError" => true + # ) + mcpServer + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} @@ -86,6 +162,9 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `agentEventSink::Function`: Callback to receive agent events +- `mcpServer`: Callable struct for MCP server communication. Called as + `mcpServer("tools/list")` to discover tools, or `mcpServer("tools/call", args)` + to execute a tool. Returns parsed JSON dicts. (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task @@ -106,6 +185,7 @@ function yiemAgent( maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, agentEventSink=agentEventSink, + mcpServer=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -114,7 +194,7 @@ function yiemAgent( # load tools (statically registered at module init) toolStore1 = toolStore(name="myagent") - register_all_tools(toolStore1) + register_all_tools(toolStore1, mcpServer) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( @@ -126,6 +206,7 @@ function yiemAgent( prepareContext, formatMsgForLLM, llmCall, + mcpServer, beforeToolCall, afterToolCall, # prepareNextTurn, diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 1d6bd52..d937d37 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -7,7 +7,7 @@ using JSON, DataStructures using ..type """ -Per-agent isolated tool storage. + Per-agent isolated tool storage. Each agent gets its own `toolStore` so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. @@ -39,61 +39,203 @@ function toolStore(; name::String="default")::toolStore toolStore(OrderedDict{String, agentTool}(), name) end +# ── MCP helper functions ──────────────────────────────────────────── + """ - listTool(store::toolStore) -> agentTool +Extract text from MCP tool result content array. -Return an `agentTool` definition for listing registered tools. +Handles MCP's content format: [{"type":"text","text":"..."}] +""" +function _extract_text_content(result::Dict)::String + content = get(result, "content", Any[]) + if content isa Vector && !isempty(content) + lines = String[] + for block in content + if block isa Dict && get(block, "type", "") == "text" + push!(lines, string(get(block, "text", ""))) + end + end + if !isempty(lines) + return join(lines, "\n") + end + end + return JSON.json(result) +end -Each call produces a **new** tool object that captures (closes over) -`store`. `register_all_tools` auto-registers one so the LLM can discover tools -at runtime. +""" +Wrap an MCP tool definition as an `agentTool`. + +The returned tool's `execute` function calls the MCP server's "tools/call" +method with the validated arguments. +""" +function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool + name = tool_def["toolName"] + title = get(tool_def, "title", name) + desc = get(tool_def, "description", "") + input_schema = get(tool_def, "inputSchema", Dict{String,Any}()) + + # Normalize inputSchema to OpenAI function format + if haskey(input_schema, "properties") && input_schema["type"] == "object" + params = Dict( + "type" => "object", + "properties" => input_schema["properties"], + "required" => get(input_schema, "required", Any[]), + ) + else + params = Dict( + "type" => "object", + "properties" => Dict{String,Any}(), + "required" => Any[], + ) + end + + return agentTool( + name=name, + label=title, + description=desc, + inputSchema=params, + execute=(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function, + llmCall=nothing) -> begin + try + response = mcpserver("tools/call", name, args) + + result_data = get(response, "result", response) + content_text = _extract_text_content(result_data) + is_error = get(result_data, "isError", false) + + return agentToolResult( + [textContent(content_text)], + Dict{Any,Any}("isError" => is_error), + nothing, false + ) + catch e + errMsg = sprint(showerror, e) + return agentToolResult( + [textContent("MCP call error: $errMsg")], + Dict{Any,Any}("error" => errMsg), + nothing, false + ) + end + end, + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, + ) +end + +""" +Discover and register MCP tools into `store.tools`. + +Queries the MCP server via `mcpserver("tools/list")`, parses the response, +and registers each discovered tool. Skips tools already registered. +Returns `(new_count, tool_list_text)`. +""" +function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} + if mcpserver === nothing + return (0, "") + end + + try + response = mcpserver("tools/list") + tools_array = response["tools"] + + new_count = 0 + for tool_def in tools_array + name = tool_def["toolName"] + if haskey(store.tools, name) + continue + end + wrapped = _wrap_mcp_tool(mcpserver, tool_def) + store.tools[name] = wrapped + new_count += 1 + end + + # Build readable tool list + lines = String[ + "- $(t.name): $(t.label) — $(t.description)" + for (k, t) in store.tools + ] + tool_list_text = "Discovered $(new_count) MCP tools. Total registered: $(length(store.tools)).\nAvailable tools:\n" * join(lines, "\n") + + return (new_count, tool_list_text) + catch e + errMsg = sprint(showerror, e) + return (0, "MCP tools/list failed: $errMsg") + end +end + +""" + listTool(store::toolStore, mcpserver) -> agentTool + +MCP-aware listTools tool. + +First call: queries the MCP server via `mcpserver("tools/list")`, registers +all discovered tools into the shared `store.tools` (in-place mutation), +then returns the full tool list. + +Subsequent calls: returns the current list (tools remain registered). + +This is the only pre-registered tool. All other tools come from the +MCP server and are loaded at runtime when the LLM calls listTools(). # Arguments -- `store`: The tool store whose tools will be listed when the tool runs +- `store`: The tool store to populate with MCP tools +- `mcpserver`: A callable struct that communicates with the MCP server. + Called as `mcpserver("tools/list")` or `mcpserver("tools/call", args)`. + Returns parsed JSON dicts. # Example ```julia -julia> store = toolStore(name="agent1"); +# User provides an MCP server client (callable struct) +mcp = MyMCPClient("nats://localhost:4222") +store = toolStore(name="agent1") +registerTool(store, listTool(store, mcp)) -julia> register_all_tools(store) # auto-registers listTools -[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) -[toolRegistry:agent1] Registered tool: listTools - -julia> tools = getTools(store) -OrderedDict{String, agentTool} with 4 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) - "writeTool" => agentTool(...) - "listTools" => agentTool(...) +# When agent calls listTools(), tools are discovered from MCP server +# and registered into store.tools in real time. ``` """ -function listTool(store::toolStore)::agentTool +function listTool(store::toolStore, mcpserver)::agentTool return agentTool( - name = "listTools", - label = "List Tools", - description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", - inputSchema = Dict{String,Any}( + name="listTools", + label="List Tools", + description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.", + inputSchema=Dict{String,Any}( "type" => "object", "properties" => Dict{String,Any}(), "required" => Any[] ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - tools = getTools(store) - if isempty(tools) - result_text = "No tools registered." + execute=(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function, llmCall=nothing) -> begin + # Discover and register MCP tools (idempotent — skips already registered) + new_count, tool_list = _register_mcp_tools(mcpserver, store) + + # Always include listTools itself in the count + total = length(store.tools) + + if new_count > 0 + result_text = tool_list else - lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools] - result_text = "Available tools:\n" * join(lines, "\n") + # Already discovered — just return current list + lines = String[ + "- $(t.name): $(t.label) — $(t.description)" + for (k, t) in store.tools + ] + result_text = "Available tools ($total):\n" * join(lines, "\n") end + return agentToolResult( [textContent(result_text)], - Dict{Any,Any}("count" => length(tools)), + Dict{Any,Any}("count" => total), nothing, false ) end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, ) end @@ -115,7 +257,7 @@ Add `tool` to `store`, overwriting any existing tool with the same name. ```julia julia> store = toolStore(name="agent1"); -julia> registerTool(store, listTool(store)) +julia> registerTool(store, listTool(store, nothing)) [toolRegistry:agent1] Registered tool: listTools OrderedDict{String, agentTool} with 1 entry: "listTools" => agentTool(...) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl deleted file mode 100644 index 09ea31f..0000000 --- a/src/tools/getTime.jl +++ /dev/null @@ -1,83 +0,0 @@ -using .type -using Dates - -""" -Validate required arguments for the getTime tool. - -Demonstrates custom validation beyond simple required-field checking: -- Ensures at least one time source (timezone or city) is provided -- Validates timezone is in IANA format if specified -- Validates city name is not empty if specified - -# Arguments -- `args::Dict{String,Any}`: Arguments from the LLM - -# Returns -- `nothing` if validation passes -- `String` error message if validation fails -""" -function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - hasTz = tz !== nothing && !isempty(tz) - hasCity = !isempty(city) - - # At least one of timezone or city is required - if !hasTz && !hasCity - return "Missing required argument: provide at least one of 'timezone' or 'city'" - end - - # Validate timezone format (IANA tz database: "Continent/City" or "Continent/City/SubCity") - if hasTz - tz_str = string(tz) - if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) - return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York' or 'Asia/Tokyo'" - end - end - - return nothing -end - -""" -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, llmCall=nothing) - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - if tz !== nothing - result = "Current time in $(tz): $(now())" - else - result = "Current time in $(city): $(now())" - end - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), nothing, false - ) -end - -""" -Define and return the getTime agentTool. -""" -function getTimeTool()::agentTool - return agentTool( - name = "getTime", - label = "Time Lookup", - description = "Get current local time for a timezone or city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"), - "city" => Dict("type" => "string", "description" => "City name as fallback") - ), - "required" => [] - ), - execute = getTimeExecute, - prepareArguments = nothing, - validateRequiredArgs = getTimeValidateRequiredArgs, - parallelToolExecute = false - ) -end diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl deleted file mode 100644 index 4f73239..0000000 --- a/src/tools/getWeather.jl +++ /dev/null @@ -1,48 +0,0 @@ -using msghandler -using .type - -""" -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, llmCall=nothing) - - agentEventSink("Getting weather...") - - city = get(args, "city", "") - units = get(args, "units", "celsius") - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), - nothing, - false - ) -end - -""" -Define and return the getWeather agentTool. -""" -function getWeatherTool()::agentTool - return agentTool( - name = "getWeather", - label = "Weather Lookup", - description = "Fetch current weather and forecast for a given city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius", "description" => "Temperature scale") - ), - "required" => ["city"] - ), - execute = getWeatherExecute, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end diff --git a/src/tools/searchWine.jl b/src/tools/searchWine.jl deleted file mode 100644 index ca1a8fc..0000000 --- a/src/tools/searchWine.jl +++ /dev/null @@ -1,397 +0,0 @@ -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 = "\n\n$table_schema\n\n\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 diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl deleted file mode 100644 index 6a913b3..0000000 --- a/src/tools/writeTool.jl +++ /dev/null @@ -1,276 +0,0 @@ -using .type -using JSON - -""" -Tool that writes new Julia tool module files to disk. - -The agent can use this tool when it encounters a task that no existing tool -can handle. Provide the tool's name, label, description, inputSchema, and -execute logic as Julia code. The tool is written to `src/tools/.jl`. - -After calling this tool, add the new file to `YiemAgent.jl` with an `include()` -statement (after `include("toolRegistry.jl")`), then restart the agent. -The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`. - -# Example - -1. Agent calls writeTool with a spec for a "searchWine" tool -2. writeTool generates src/tools/searchWine.jl -3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl -4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() -5. Restart agent — new tool is available - -# How It Works - -writeTool is a **file writer**, not a code generator. The LLM provides the -tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: - - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string - - Indents `executeCode` with 4 spaces - - Wraps it inside `function executeTool(...)::agentToolResult ... end` - - Appends `writeToolTool()` returning an `agentTool` struct - - Writes the combined string to `src/tools/.jl` - -# Important Notes - -- The `executeCode` string is embedded literally into the generated tool. - Use `args["param_name"]` to access input parameters. -- The code string should be the function body (NOT wrapped in a function). - Lines will be indented with 4 spaces inside the execute function. -- Tool names must be valid Julia identifiers (lowercase letters, digits, underscores, - no leading digits or special characters). -""" - -""" -Validate that a tool name is a valid Julia identifier. -""" -function validateToolName(name::String)::Union{Nothing,String} - if !occursin(r"^[a-zA-Z_][a-zA-Z0-9_!]*$", name) - return "Invalid tool name: '$name'. Tool names must be valid Julia identifiers (letters, digits, underscores, starting with a letter or underscore)." - end - return nothing -end - -""" -Indent a multi-line code string by the specified number of spaces. -""" -function indent_code(code::String, n::Int)::String - prefix = " "^n - lines = split(code, '\n') - result_lines = String[prefix * line for line in lines] - return join(result_lines, "\n") -end - -""" -Convert a Julia Dict to a valid Julia Dict{String,Any}(...) literal string. -""" -function dict_to_julia_literal(d)::String - if d isa Dict - items = String[] - for (k, v) in d - key_str = json_string(k) - val_str = value_to_julia(v) - push!(items, "$key_str => $val_str") - end - return "Dict{String,Any}(" * join(items, ", ") * ")" - else - return value_to_julia(d) - end -end - -function value_to_julia(v)::String - if v isa Dict - return dict_to_julia_literal(v) - elseif v isa Vector - items = [value_to_julia(x) for x in v] - return "[" * join(items, ", ") * "]" - elseif v isa String - escaped = replace(v, "\\" => "\\\\") - escaped = replace(escaped, "\"" => "\\\"") - return "\"$escaped\"" - elseif v isa Number - return string(v) - elseif v isa Bool - return string(v) - elseif v === nothing - return "nothing" - else - return "\"$(v)\"" - end -end - -""" -Convert any Julia value to a JSON string. -""" -function json_string(v)::String - return JSON.json(v) -end - -""" -Define and return the writeTool agentTool. -""" -function writeToolTool()::agentTool - return agentTool( - name = "writeTool", - label = "Create Tool", - description = "Write a new Julia tool module file to src/tools/.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"), - "label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"), - "description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"), - "inputSchema" => Dict( - "type" => "object", - "description" => "JSON Schema describing tool parameters in MCP format" - ), - "executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."), - "validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."), - "prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."), - "parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools") - ), - "required" => ["name", "label", "description", "inputSchema", "executeCode"] - ), - 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 - tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} - execute_code = get(args, "executeCode", "")::String - validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} - prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} - parallel = get(args, "parallel", false)::Bool - - # Validate tool name - name_err = validateToolName(tool_name) - if name_err !== nothing - return agentToolResult( - [textContent(name_err)], - Dict{Any,Any}(), nothing, false - ) - end - - # Validate required fields - if isempty(tool_name) - return agentToolResult( - [textContent("Missing required field: 'name'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(tool_description) - return agentToolResult( - [textContent("Missing required field: 'description'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(execute_code) - return agentToolResult( - [textContent("Missing required field: 'executeCode'")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Generating tool: $tool_name")) - - # Build the tool file path - script_dir = dirname(@__FILE__) - tools_dir = dirname(script_dir) - filepath = joinpath(tools_dir, "$(tool_name).jl") - - # Check for naming conflicts - if isfile(filepath) - return agentToolResult( - [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) - - # Convert schema Dict to a Julia Dict literal string - schema_literal = dict_to_julia_literal(tool_schema) - - # Build optional validation function - validate_section = if validate_code !== nothing && !isempty(validate_code) - indented = indent_code(validate_code, 4) - "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" - else - "" - end - - # Build optional prepare function - prepare_section = if prepare_code !== nothing && !isempty(prepare_code) - indented = indent_code(prepare_code, 4) - "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" - else - "" - end - - # Indent user's execute code for embedding inside execute function body - indented_exec = indent_code(execute_code, 4) - - # Escape description for Julia string literal - escaped_desc = replace(tool_description, "\\" => "\\\\") - escaped_desc = replace(escaped_desc, "\"" => "\\\"") - - # Build the complete tool file content - parts = String[] - push!(parts, "# Auto-generated tool: $tool_name\n") - push!(parts, "# Generated by writeTool at $(now())\n\n") - if !isempty(validate_section) - push!(parts, validate_section) - push!(parts, "\n") - end - if !isempty(prepare_section) - push!(parts, prepare_section) - push!(parts, "\n") - end - push!(parts, "\n") - push!(parts, "# Execute function\n") - push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") - push!(parts, "$indented_exec\n") - push!(parts, "end\n\n") - push!(parts, "# Tool definition\n") - push!(parts, "function getTool()::agentTool\n") - push!(parts, " return agentTool(\n") - push!(parts, " name = \"$(tool_name)\",\n") - push!(parts, " label = \"$(tool_label)\",\n") - push!(parts, " description = \"$(escaped_desc)\",\n") - push!(parts, " inputSchema = $schema_literal,\n") - push!(parts, " execute = executeTool,\n") - if validate_code !== nothing && !isempty(validate_code) - push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") - else - push!(parts, " validateRequiredArgs = nothing,\n") - end - if prepare_code !== nothing && !isempty(prepare_code) - push!(parts, " prepareArguments = prepareArguments,\n") - else - push!(parts, " prepareArguments = nothing,\n") - end - push!(parts, " parallelToolExecute = $parallel\n") - push!(parts, " )\n") - push!(parts, "end\n") - - tool_code = join(parts) - - # Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools() - write(filepath, tool_code) - - onPartialResult(Dict("status" => "Done")) - - return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")], - Dict{Any,Any}( - "file" => filepath, - "name" => tool_name, - "label" => tool_label, - "description" => tool_description, - ), - nothing, false - ) - end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -- 2.52.0 From f62b8f14e709903085dc25755e03fd94b9b53774 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 20 Aug 2026 18:45:39 +0700 Subject: [PATCH 094/112] update --- README.md | 141 ++++++++++++++++++++++++++++++++++++++------ etc.jl | 41 ------------- src/agentCore.jl | 70 +++++++++++----------- src/toolRegistry.jl | 69 ++++++++++++++++++---- 4 files changed, 216 insertions(+), 105 deletions(-) delete mode 100644 etc.jl diff --git a/README.md b/README.md index ed1558c..73e0c3f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # YiemAgent -Julia framework for building agents with tool use. +Julia framework for building agents with tool use and MCP (Model Context Protocol) support. ## Getting Started -1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` -2. Create a `yiemAgent` with `loadTools("src/tools")` -3. Call `runAgent(agent, "message")` then `takeResponse(agent)` +1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, NATS, DataFrames` +2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS +3. Create a `yiemAgent` with an LLM callable and MCP server: + ```julia + agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, agentEventSink=yourSink) + ``` +4. Call `runAgent(agent, message)` then `takeResponse(agent)` ## Architecture @@ -14,21 +18,122 @@ Julia framework for building agents with tool use. src/ ├── YiemAgent.jl # Module entry point ├── type.jl # Core types (messages, tools, agent state) -├── utils.jl # Message formatting, validation +├── utils.jl # Message formatting, context preparation, validation ├── agentCore.jl # Agent loop, tool execution pipeline -├── api.jl # Public API (runAgent, takeResponse, etc.) -└── tools/ - ├── registry.jl # Tool registry (loadTools, registerTool, listTools) - ├── getWeather.jl # Weather lookup tool - ├── getTime.jl # Time lookup tool - ├── writeTool.jl # Create new tool files (self-modifying) - └── README.md # Tool development guide +├── api.jl # Public API (runAgent, takeResponse, followUp, stopAgent) +└── toolRegistry.jl # Tool store, MCP discovery, listTools registration ``` -## Tool Development +## MCP Protocol -See `src/tools/README.md` for: -- Tool anatomy (schema, execute, getTool) -- Validation hooks -- Agent loop lifecycle -- Self-modifying tools (`writeTool`) +YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement: + +```julia +# tools/list — returns tool definitions +mcpServer("tools/list") # → Dict("jsonrpc"=>"2.0", "id"=>1, "result"=>Dict("tools"=>[...], "nextCursor"=>...)) + +# tools/call — executes a tool +mcpServer("tools/call", toolName, arguments) # → Dict("jsonrpc"=>"2.0", "id"=>2, "result"=>Dict("content"=>[...], "isError"=>...)) +``` + +Protocol error responses include `"error"` instead of `"result"`: +```julia +Dict("jsonrpc"=>"2.0", "id"=>2, "error"=>Dict("code"=>-32602, "message"=>"...")) +``` + +### Implementing the MCP Server Client + +You must provide a callable struct that communicates with your MCP server. Example using NATS: + +```julia +struct mcpServer + natsConn::NATS.Connection + topic::String + senderID::String + fileserver_url::String +end + +# tools/list implementation +function (c::mcpServer)(method::String) + if method != "tools/list" + error("mcpServer: unexpected method '$method' (expected 'tools/list')") + end + + payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}()) + payloads = [("payload", payload, "dictionary")] + _, msg_envelope_json_str = msghandler.smartpack( + c.topic, payloads; + sender_id=c.senderID, + msg_purpose="mcp_tools_list", + fileserver_url=c.fileserver_url) + + reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) + incoming_env = msghandler.smartunpack(String(reply.payload)) + return incoming_env["payloads"][1][2] +end + +# tools/call implementation +function (c::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}) + if method != "tools/call" + error("mcpServer: unexpected method '$method' (expected 'tools/call')") + end + + payload = Dict( + "jsonrpc" => "2.0", + "id" => 2, + "method" => method, + "params" => Dict( + "name" => toolName, + "arguments" => arguments + ) + ) + payloads = [("payload", payload, "dictionary")] + _, msg_envelope_json_str = msghandler.smartpack( + c.topic, payloads; + sender_id=c.senderID, + msg_purpose="mcp_tool_call", + fileserver_url=c.fileserver_url) + + reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) + incoming_env = msghandler.smartunpack(String(reply.payload)) + return incoming_env["payloads"][1][2] +end +``` + +See `test/runtest.jl` for the complete working example. + +## Tool Discovery + +Tools are discovered dynamically via MCP server: + +1. `listTool` is the only pre-registered tool +2. When the LLM calls `listTools()`, tools are discovered from the MCP server and registered at runtime +3. Supports pagination via `nextCursor` for large tool sets + +## Agent API + +| Function | Description | +|----------|-------------| +| `runAgent(agent, msg)` | Send a message to the agent's input channel | +| `takeResponse(agent)` | Block and take the agent's response from output channel | +| `followUp(agent, msg)` | Send a follow-up message while agent is still processing | +| `stopAgent(agent)` | Gracefully stop the agent and close channels | + +## Agent Lifecycle + +1. `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel` +2. User messages enter via `runAgent()` or `followUp()` +3. `_processMessage()` handles LLM calls, tool execution, and conversation history +4. Tool execution follows three phases: `prepareToolCall` → `executePreparedToolCall` → `finalizeExecutedToolCall` +5. `beforeToolCall`/`afterToolCall` hooks allow pre/post-processing of tool calls +6. Tool `execute` functions can set `terminate=true` to stop the agent loop + +## Hooks + +| Hook | Signature | Purpose | +|------|-----------|---------| +| `prepareContext` | `(state, sink, llmCall) -> ctx` | Transform messages/context before LLM call | +| `formatMsgForLLM` | `(ctx, sink) -> dict` | Convert agent context to LLM API format | +| `beforeToolCall` | `(context, signal) -> result` | Block/allow tool execution | +| `afterToolCall` | `(context, signal) -> result` | Post-process tool results | +| `agentEventSink` | `(msg) -> nothing` | Callback for agent events/debug messages | diff --git a/etc.jl b/etc.jl deleted file mode 100644 index 2a3a008..0000000 --- a/etc.jl +++ /dev/null @@ -1,41 +0,0 @@ -# "tools/list" input: -mcpServer("tools/list") -# sending out payload before smart packed by msghandler -Dict( - "method"=> "tools/list" -) -# expected return after smart unpacked by msghandler -Dict("tools" => [ - Dict("toolName" => "getWeather", - "title" => "Weather Lookup", - "description" => "Fetch current weather for a city.", - "inputSchema" => Dict("type"=>"object", - "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), - "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), - "required" => ["city"]) - ) -]) - -# "tools/call" input: -mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) -# sending out payload before smart packed by msghandler -Dict( - "method"=> "tools/call", - "tools"=> Dict("toolName"=>"getWeather", "arguments"=>Dict("city"=>"Tokyo", "units"=>"celsius")) -) -# expected return after smart unpacked by msghandler -Dict( - "toolName"=>"getWeather", - "content": [{"type": "text", "text": "Weather in Tokyo: Sunny, 22°C"}], - "isError": false -) - -# If an error occurs, mcpServer returns after smart unpacked by msghandler: -Dict( - "toolName"=>"getWeather", - "content": [], - "error": Dict("code"=>1, "message"=>"City not found"), - "isError": true -) - - diff --git a/src/agentCore.jl b/src/agentCore.jl index f0d2e14..7e1022d 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -51,8 +51,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper llmCall # Callable struct for MCP server communication. - # Called as: mcpServer("tools/list") → returns parsed JSON dict of available tools - # mcpServer("tools/call", toolName, arguments) → returns tool result as parsed JSON dict + # Called as: mcpServer("tools/list") → returns JSON-RPC 2.0 parsed response + # mcpServer("tools/call", toolName, arguments) → returns JSON-RPC 2.0 parsed response # # # Example (weather tool) # # User provides a callable struct @@ -64,8 +64,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # end # # function (c::MyMCPClient)(method::String) - # payload = Dict("method"=> method) - # payloads = [("method", payload, "dictionary")] + # payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}()) + # payloads = [("payload", payload, "dictionary")] # _, msg_envelope_json_str = msghandler.smartpack( # c.topic, payloads; sender_id=c.senderID, # msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url) @@ -75,8 +75,9 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # end # # function (c::MyMCPClient)(method::String, toolName::String, arguments::Dict{String,Any}) - # payload = Dict("method"=> method, "toolName"=> toolName, "arguments"=>arguments) - # payloads = [("method", payload, "dictionary"),] + # payload = Dict("jsonrpc" => "2.0", "id" => 2, "method" => method, + # "params" => Dict("name" => toolName, "arguments" => arguments)) + # payloads = [("payload", payload, "dictionary"),] # _, msg_envelope_json_str = msghandler.smartpack( # c.topic, payloads; sender_id=c.senderID, # msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url) @@ -88,42 +89,41 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # # "tools/list" input: # mcpServer("tools/list") # # sending out payload before smart packed by msghandler: - # Dict("method"=> "tools/list") + # Dict("jsonrpc" => "2.0", "id" => 1, "method" => "tools/list", "params" => Dict{String, Any}()) # # expected return after smart unpacked by msghandler: - # Dict("tools" => [ - # Dict( - # "toolName" => "getWeather", - # "title" => "Weather Lookup", - # "description" => "Fetch current weather for a city.", - # "inputSchema" => Dict("type"=>"object", - # "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), - # "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), - # "required" => ["city"]) + # Dict( + # "jsonrpc" => "2.0", "id" => 1, + # "result" => Dict( + # "tools" => [ + # Dict( + # "name" => "getWeather", + # "description" => "Fetch current weather for a city.", + # "inputSchema" => Dict("type"=>"object", + # "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), + # "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), + # "required" => ["city"]) + # ) + # ], + # "nextCursor" => nothing # ) - # ]) + # ) # # # "tools/call" input: # mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) # # sending out payload before smart packed by msghandler: - # Dict( - # "method"=> "tools/call", - # "toolName"=>"getWeather", - # "arguments"=>Dict("city"=>"Tokyo", "units"=>"celsius") - # ) + # Dict("jsonrpc" => "2.0", "id" => 2, "method" => "tools/call", + # "params" => Dict("name" => "getWeather", "arguments" => Dict("city"=>"Tokyo", "units"=>"celsius"))) # # expected return after smart unpacked by msghandler (success): - # Dict( - # "toolName"=> "getWeather", - # "content" => [{"type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C"}], - # "error"=> "", - # "isError" => false - # ) - # # expected return after smart unpacked by msghandler (error): - # Dict( - # "toolName"=> "getWeather", - # "content" => [], - # "error" => Dict("code"=>1, "message"=>"City not found"), - # "isError" => true - # ) + # Dict("jsonrpc" => "2.0", "id" => 2, + # "result" => Dict("content" => [{"type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C"}], + # "isError" => false)) + # # expected return after smart unpacked by msghandler (tool error): + # Dict("jsonrpc" => "2.0", "id" => 2, + # "result" => Dict("content" => [{"type" => "text", "text" => "Error: City 'Atlantis' not found."}], + # "isError" => true)) + # # expected return after smart unpacked by msghandler (protocol error): + # Dict("jsonrpc" => "2.0", "id" => 2, + # "error" => Dict("code" => -32602, "message" => "Missing required argument 'city'")) mcpServer # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index d937d37..26e56b7 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -44,7 +44,10 @@ end """ Extract text from MCP tool result content array. -Handles MCP's content format: [{"type":"text","text":"..."}] +Handles JSON-RPC 2.0 result content format: + {"content": [{"type": "text", "text": "..."}], "isError": false} + +Returns the joined text content, or JSON-serialized fallback if no text blocks found. """ function _extract_text_content(result::Dict)::String content = get(result, "content", Any[]) @@ -66,11 +69,12 @@ end Wrap an MCP tool definition as an `agentTool`. The returned tool's `execute` function calls the MCP server's "tools/call" -method with the validated arguments. +method with the validated arguments. Responses are in JSON-RPC 2.0 format +with result/error envelopes handled by the execute function. """ function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool - name = tool_def["toolName"] - title = get(tool_def, "title", name) + name = tool_def["name"] + title = get(tool_def, "title", get(tool_def, "label", name)) desc = get(tool_def, "description", "") input_schema = get(tool_def, "inputSchema", Dict{String,Any}()) @@ -101,6 +105,17 @@ function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool try response = mcpserver("tools/call", name, args) + # Parse JSON-RPC 2.0 response envelope + if haskey(response, "error") + rpc_error = response["error"] + err_msg = get(rpc_error, "message", "Unknown MCP error") + return agentToolResult( + [textContent("MCP error: $err_msg")], + Dict{Any,Any}("error" => err_msg), + nothing, false + ) + end + result_data = get(response, "result", response) content_text = _extract_text_content(result_data) is_error = get(result_data, "isError", false) @@ -128,9 +143,10 @@ end """ Discover and register MCP tools into `store.tools`. -Queries the MCP server via `mcpserver("tools/list")`, parses the response, -and registers each discovered tool. Skips tools already registered. -Returns `(new_count, tool_list_text)`. +Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format), +parses the response envelope, and registers each discovered tool. +Handles pagination via `nextCursor` — keeps fetching until cursor is empty. +Skips tools already registered. Returns `(new_count, tool_list_text)`. """ function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} if mcpserver === nothing @@ -139,11 +155,23 @@ function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} try response = mcpserver("tools/list") + + # Parse JSON-RPC 2.0 response envelope + if haskey(response, "error") + rpc_error = response["error"] + err_msg = get(rpc_error, "message", "Unknown MCP error") + return (0, "MCP tools/list failed: $err_msg") + end + if haskey(response, "result") + response = response["result"] + end + tools_array = response["tools"] + cursor = get(response, "nextCursor", nothing) new_count = 0 for tool_def in tools_array - name = tool_def["toolName"] + name = tool_def["name"] if haskey(store.tools, name) continue end @@ -152,6 +180,25 @@ function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} new_count += 1 end + # Paginate: fetch remaining tools if nextCursor is present + while cursor !== nothing && cursor !== "" + response = mcpserver("tools/list") + if haskey(response, "result") + response = response["result"] + end + tools_array = get(response, "tools", Any[]) + cursor = get(response, "nextCursor", nothing) + for tool_def in tools_array + name = tool_def["name"] + if haskey(store.tools, name) + continue + end + wrapped = _wrap_mcp_tool(mcpserver, tool_def) + store.tools[name] = wrapped + new_count += 1 + end + end + # Build readable tool list lines = String[ "- $(t.name): $(t.label) — $(t.description)" @@ -169,11 +216,11 @@ end """ listTool(store::toolStore, mcpserver) -> agentTool -MCP-aware listTools tool. +MCP-aware listTools tool (JSON-RPC 2.0 protocol). First call: queries the MCP server via `mcpserver("tools/list")`, registers -all discovered tools into the shared `store.tools` (in-place mutation), -then returns the full tool list. +all discovered tools into the shared `store.tools` (in-place mutation, with +pagination via nextCursor), then returns the full tool list. Subsequent calls: returns the current list (tools remain registered). -- 2.52.0 From 3c91222462c03641bb49a1377bd53adb9ba13dd0 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 21 Aug 2026 07:10:49 +0700 Subject: [PATCH 095/112] update --- src/agentCore.jl | 20 ++- src/toolRegistry.jl | 4 +- test/toolTest.jl | 367 -------------------------------------------- 3 files changed, 15 insertions(+), 376 deletions(-) delete mode 100644 test/toolTest.jl diff --git a/src/agentCore.jl b/src/agentCore.jl index 7e1022d..ab0d5f4 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -310,13 +310,13 @@ function _agentLoop(agent::yiemAgent) else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") result = deepcopy(agent._state.messages[end]) - + agent.agentEventSink("_agentLoop 4-1 ") # filter out reasoningContent in-place filter!(c -> !(c isa reasoningContent), result.content) - + agent.agentEventSink("_agentLoop 5 ") # format output respondToUI = _assistantMessageToOpenAI(result) - + agent.agentEventSink("_agentLoop 6 ") put!(agent.outputChannel, respondToUI) if !isempty(result.content) && result.content[1] isa textContent agent.agentEventSink(result.content[1].text) @@ -376,6 +376,13 @@ function _agentLoop(agent::yiemAgent) end end catch e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + agentEventSink(errMsg) + # On any error, send error response and exit the loop @error "Agent loop failed" error=e end @@ -1085,13 +1092,12 @@ function executePreparedToolCall( llmCall::Union{Any,Nothing}=nothing, )::executedOutcome agentEventSink("executePreparedToolCall 1") - agentEventSink("executePreparedToolCall 2") - agentEventSink("executePreparedToolCall 3") - try + try #WORKING result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink, llmCall) + agentEventSink("executePreparedToolCall 2") agentEventSink(result.content[1].text) - agentEventSink("executePreparedToolCall 4") + agentEventSink("executePreparedToolCall 3") return executedOutcome(result, false) catch e bt = catch_backtrace() diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 26e56b7..8381d2e 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -100,7 +100,7 @@ function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool inputSchema=params, execute=(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult::Function, + agentEventSink, llmCall=nothing) -> begin try response = mcpserver("tools/call", name, args) @@ -256,7 +256,7 @@ function listTool(store::toolStore, mcpserver)::agentTool ), execute=(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult::Function, llmCall=nothing) -> begin + agentEventSink, llmCall=nothing) -> begin # Discover and register MCP tools (idempotent — skips already registered) new_count, tool_list = _register_mcp_tools(mcpserver, store) diff --git a/test/toolTest.jl b/test/toolTest.jl deleted file mode 100644 index f613397..0000000 --- a/test/toolTest.jl +++ /dev/null @@ -1,367 +0,0 @@ -using Test -using Dates -using YiemAgent -using YiemAgent.toolRegistry -using YiemAgent.type -using YiemAgent.agentCore - -@testset "register_all_tools with toolStore" begin - - # ------------------------------------------------------------------ # - # 1. register_all_tools registers all static tools + listTools # - # ------------------------------------------------------------------ # - store = toolStore(name="test1") - loaded = register_all_tools(store) - @test !isempty(loaded) - @test length(loaded) == 4 # getWeather + getTime + writeTool + listTools - - names = [k for k in keys(loaded)] - @test "getTime" in names - @test "getWeather" in names - @test "writeTool" in names - @test "listTools" in names - - # ------------------------------------------------------------------ # - # 2. register_all_tools returns tools in registration order # - # ------------------------------------------------------------------ # - @test collect(keys(loaded))[1] == "getWeather" - @test collect(keys(loaded))[2] == "getTime" - @test collect(keys(loaded))[3] == "writeTool" - @test collect(keys(loaded))[4] == "listTools" - - # ------------------------------------------------------------------ # - # 3. Verify loaded tool fields are correct # - # ------------------------------------------------------------------ # - # getTime - time_tool = loaded["getTime"] - @test time_tool.name == "getTime" - @test time_tool.label == "Time Lookup" - @test time_tool.validateRequiredArgs !== nothing - @test time_tool.parallelToolExecute == false - @test time_tool.inputSchema["required"] == Any[] - - # getWeather - weather = loaded["getWeather"] - @test weather.name == "getWeather" - @test weather.label == "Weather Lookup" - @test weather.execute !== nothing - @test weather.parallelToolExecute == false - @test weather.inputSchema["required"] == ["city"] - - # writeTool - wt = loaded["writeTool"] - @test wt.name == "writeTool" - @test wt.label == "Create Tool" - @test wt.execute !== nothing - @test "name" in wt.inputSchema["required"] - @test "executeCode" in wt.inputSchema["required"] - - # ------------------------------------------------------------------ # - # 4. Tool execution returns valid results # - # ------------------------------------------------------------------ # - sig = nothing - op = x -> x # no-op partial result callback - - # execute getTime - result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op) - @test result_t isa agentToolResult - @test result_t.content[1] isa textContent - @test occursin("Tokyo", result_t.content[1].text) - - # execute getTime with timezone - result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op) - @test result_tz isa agentToolResult - @test occursin("America/New_York", result_tz.content[1].text) - - # execute getWeather - result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op) - @test result_w isa agentToolResult - @test result_w.content[1] isa textContent - @test occursin("Bangkok", result_w.content[1].text) - - # execute getWeather with units - result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) - @test occursin("72\u00b0F", result_w2.content[1].text) - - # ------------------------------------------------------------------ # - # 5. getTools / registerTool / clearTools (per-store isolation) # - # ------------------------------------------------------------------ # - store3 = toolStore(name="test3") - registry_tools = getTools(store3) - @test isempty(registry_tools) - - # Register tools manually - registerTool(store3, loaded["getTime"]) - registerTool(store3, loaded["getWeather"]) - registerTool(store3, loaded["writeTool"]) - - reg = getTools(store3) - @test !isempty(reg) - @test "getTime" in keys(reg) - @test "getWeather" in keys(reg) - @test "writeTool" in keys(reg) - @test collect(keys(reg))[1] == "getTime" - @test collect(keys(reg))[2] == "getWeather" - @test collect(keys(reg))[3] == "writeTool" - - clearTools(store3) - @test isempty(getTools(store3)) - - test_tool = agentTool( - name = "manualTool", - label = "Manual Tool", - description = "Registered manually", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = true - ) - registerTool(store3, test_tool) - reg = getTools(store3) - @test haskey(reg, "manualTool") - @test length(reg) == 1 - @test reg["manualTool"].parallelToolExecute == true - - # ------------------------------------------------------------------ # - # 6. getTools returns direct reference (mutations affect registry) # - # ------------------------------------------------------------------ # - copy1 = getTools(store3) - copy2 = getTools(store3) - @test copy1 === copy2 # same reference, not a deep copy - empty!(copy1) - @test isempty(getTools(store3)) # mutation propagates - - # ------------------------------------------------------------------ # - # 7. Per-store isolation — two stores don't share tools # - # ------------------------------------------------------------------ # - storeA = toolStore(name="isolationA") - storeB = toolStore(name="isolationB") - - registerTool(storeA, loaded["getTime"]) - registerTool(storeB, loaded["getWeather"]) - - regA = getTools(storeA) - regB = getTools(storeB) - - @test "getTime" in keys(regA) - @test "getWeather" ∉ keys(regA) - @test "getWeather" in keys(regB) - @test "getTime" ∉ keys(regB) - - clearTools(storeA) - @test isempty(getTools(storeA)) - @test !isempty(getTools(storeB)) # storeB unaffected -end - -@testset "listTool" begin - store = toolStore(name="test_list") - register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools - - # register_all_tools auto-registers listTool - @test "listTools" in keys(store.tools) - - # listTool returns an agentTool, not a string or array - list_t = listTool(store) - @test list_t isa agentTool - @test list_t.name == "listTools" - @test list_t.label == "List Tools" - @test isempty(list_t.inputSchema["required"]) - - # Verify all tools appear (3 loaded + listTools = 4) - result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x) - @test result isa agentToolResult - @test result.content[1] isa textContent - @test occursin("listTools", result.content[1].text) - @test occursin("getWeather", result.content[1].text) - @test occursin("getTime", result.content[1].text) - @test occursin("writeTool", result.content[1].text) - @test result.details["count"] == 4 - - # Each listTool call creates an independent closure - storeB = toolStore(name="test_listB") - registerTool(storeB, store.tools["getWeather"]) - list_tB = listTool(storeB) - - resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) - resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x) - - @test occursin("getWeather", resultA.content[1].text) - @test occursin("getWeather", resultB.content[1].text) - @test occursin("getTime", resultA.content[1].text) - @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather -end - -@testset "executePreparedToolCall with static tools" begin - # Tests executePreparedToolCall with statically loaded tools. - # The world-age issue is resolved because tool.execute comes from - # a statically included module, not a dynamically created one. - - store = toolStore(name="test_static") - register_all_tools(store) - - weather_tool = store.tools["getWeather"] - - # Create a preparedToolCall that mimics what prepareToolCall() returns - tool_call = agentToolCall( - "function", "call-static-1", "getWeather", - Dict{String,Any}("city" => "San Francisco") - ) - prep = preparedToolCall( - weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco") - ) - sig = abortSignal(false) - - # This call goes through: executePreparedToolCall -> prep.tool.execute(...) - result = executePreparedToolCall( - prep, sig, x -> nothing - ) - - @test result isa executedOutcome - @test result.isError == false - @test result.result.content[1] isa textContent - @test occursin("San Francisco", result.result.content[1].text) -end - -@testset "executePreparedToolCall with validation (static tools)" begin - # Tests executePreparedToolCall with a tool that has custom validation hooks. - # This exercises the full tool execution path including validation. - - store = toolStore(name="test_static_validate") - register_all_tools(store) - - time_tool = store.tools["getTime"] - - tool_call = agentToolCall( - "function", "call-static-2", "getTime", - Dict{String,Any}("timezone" => "America/New_York") - ) - prep = preparedToolCall( - time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York") - ) - sig = abortSignal(false) - - result = executePreparedToolCall( - prep, sig, x -> nothing - ) - - @test result isa executedOutcome - @test result.isError == false - @test result.result.content[1] isa textContent - @test occursin("America/New_York", result.result.content[1].text) -end - -@testset "executeToolCallsSequential with static tools (full pipeline)" begin - # Tests the full tool execution pipeline: executeToolCallsSequential - # which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall - # with statically loaded tools. - - store = toolStore(name="test_full_pipeline") - register_all_tools(store) - - # Build agentContext from the store's tools - tools = getTools(store) - ctx = agentContext( - "test system prompt", - agentMessage[], - tools - ) - - # Create an assistant message containing tool calls - assistant_msg = assistantMessage( - role="assistant", - content=Vector{messageContent}(), - api="openai", - provider="test", - model="test-model", - usage=llmUsage(0, 0), - stopReason="tool_calls", - errorMessage=nothing, - timestamp=now() - ) - - # Create tool calls for multiple statically loaded tools - tool_calls = [ - agentToolCall( - "function", "call-seq-1", "getWeather", - Dict{String,Any}("city" => "Tokyo") - ), - agentToolCall( - "function", "call-seq-2", "getTime", - Dict{String,Any}("timezone" => "Europe/London") - ), - ] - - config = agentLoopConfig( - nothing, nothing, "sequential" - ) - sig = abortSignal(false) - - # Execute the full pipeline - batch = executeToolCallsSequential( - ctx, assistant_msg, tool_calls, config, sig, x -> nothing - ) - - @test batch.messages isa Vector{toolResultMessage} - @test length(batch.messages) == 2 - @test batch.messages[1].toolName == "getWeather" - @test batch.messages[1].isError == false - @test occursin("Tokyo", batch.messages[1].content[1].text) - @test batch.messages[2].toolName == "getTime" - @test batch.messages[2].isError == false - @test occursin("Europe/London", batch.messages[2].content[1].text) -end - -@testset "executeToolCallsParallel with static tools (full pipeline)" begin - # Same as above but tests parallel execution path. - - store = toolStore(name="test_parallel") - register_all_tools(store) - - tools = getTools(store) - ctx = agentContext( - "test system prompt", - agentMessage[], - tools - ) - - assistant_msg = assistantMessage( - role="assistant", - content=Vector{messageContent}(), - api="openai", - provider="test", - model="test-model", - usage=llmUsage(0, 0), - stopReason="tool_calls", - errorMessage=nothing, - timestamp=now() - ) - - tool_calls = [ - agentToolCall( - "function", "call-par-1", "getWeather", - Dict{String,Any}("city" => "Paris") - ), - agentToolCall( - "function", "call-par-2", "getTime", - Dict{String,Any}("city" => "Sydney") - ), - ] - - config = agentLoopConfig( - nothing, nothing, "parallel" - ) - sig = abortSignal(false) - - batch = executeToolCallsParallel( - ctx, assistant_msg, tool_calls, config, sig, x -> nothing - ) - - @test batch.messages isa Vector{toolResultMessage} - @test length(batch.messages) == 2 - @test batch.messages[1].toolName == "getWeather" - @test batch.messages[1].isError == false - @test batch.messages[2].toolName == "getTime" - @test batch.messages[2].isError == false -end -- 2.52.0 From c59f6bfa614ae2e5652b4b425078244962044031 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 21 Aug 2026 07:13:52 +0700 Subject: [PATCH 096/112] update --- src/agentCore.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index ab0d5f4..4048e46 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -994,7 +994,10 @@ function prepareToolCall( agentEventSink )::Union{preparedToolCall,immediateOutcome} agentEventSink("prepareToolCall 1") - tool = get(context.tools, toolCall.name, nothing) # pick a called tool from tool store + + # pick a called tool from tool store + tool = get(context.tools, toolCall.name, nothing) + if tool === nothing agentEventSink("prepareToolCall 2") return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) -- 2.52.0 From da21790263455d8d60811d354f81eaeafa80c624 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 21 Aug 2026 13:13:38 +0700 Subject: [PATCH 097/112] update --- README.md | 4 +- README_tools.md | 64 ++++++------ src/YiemAgent.jl | 15 +-- src/agentCore.jl | 207 ++++++++++++++++++------------------- src/toolRegistry.jl | 245 +++++++++++++++++++------------------------- src/type.jl | 16 +-- src/utils.jl | 38 +++---- user_code.jl | 10 +- 8 files changed, 276 insertions(+), 323 deletions(-) diff --git a/README.md b/README.md index 73e0c3f..2f723ef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Julia framework for building agents with tool use and MCP (Model Context Protoco 2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS 3. Create a `yiemAgent` with an LLM callable and MCP server: ```julia - agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, agentEventSink=yourSink) + agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, eventSink=yourSink) ``` 4. Call `runAgent(agent, message)` then `takeResponse(agent)` @@ -136,4 +136,4 @@ Tools are discovered dynamically via MCP server: | `formatMsgForLLM` | `(ctx, sink) -> dict` | Convert agent context to LLM API format | | `beforeToolCall` | `(context, signal) -> result` | Block/allow tool execution | | `afterToolCall` | `(context, signal) -> result` | Post-process tool results | -| `agentEventSink` | `(msg) -> nothing` | Callback for agent events/debug messages | +| `eventSink` | `(msg) -> nothing` | Callback for agent events/debug messages | diff --git a/README_tools.md b/README_tools.md index 9560f36..c6ab65d 100644 --- a/README_tools.md +++ b/README_tools.md @@ -89,7 +89,7 @@ using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry # Create agent — tools are registered automatically via register_all_tools() agent = yiemAgent( llmCall = my_llm_call, # Function that calls the LLM API - agentEventSink = my_event_sink, # Function for TUI/logging + eventSink = my_event_sink, # Function for TUI/logging ) ``` @@ -98,7 +98,7 @@ agent = yiemAgent( | Parameter | Type | Required | Purpose | |-----------|------|----------|---------| | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | -| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | +| `eventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | | `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | | `model` | `llmModel` | No | LLM model config | | `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | @@ -179,7 +179,7 @@ Each phase has a single responsibility and produces an intermediate result: | Phase | Function | Input | Output | Purpose | |-------|----------|-------|--------|---------| | Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | -| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results | +| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `eventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results | | Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. @@ -222,7 +222,7 @@ end ```julia execute(toolCallId::String, - args::Dict{String,Any}, + args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult ``` @@ -430,12 +430,12 @@ function _processMessage(agent::yiemAgent)::assistantMessage # ── Step 2: Prepare context ───────────────────────────────── state = agentState(systemPrompt, nothing, tools, messages) - preparedContext = prepareContext(state, agentEventSink) + preparedContext = prepareContext(state, eventSink) # Default: deep copies systemPrompt, messages, tools from agentState → agentContext # Override point: filter tools, inject context, modify system prompt # ── Step 3: Format for LLM ────────────────────────────────── - formattedMessages = formatMsgForLLM(preparedContext, agentEventSink) + formattedMessages = formatMsgForLLM(preparedContext, eventSink) # Converts agentContext → Dict("messages" => [...]) in OpenAI format # Wraps systemPrompt as system role, converts each messageContent block @@ -456,7 +456,7 @@ function _processMessage(agent::yiemAgent)::assistantMessage signal = abortSignal(false) # Execute tool calls (sequential or parallel) - batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink) + batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, eventSink) # Save results to conversation history for tool_result in batch.messages @@ -611,7 +611,7 @@ function prepareToolCall( function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing, abortSignal}, - agentEventSink, + eventSink, )::executedOutcome ``` @@ -623,7 +623,7 @@ function executePreparedToolCall( prep.toolCall.id, prep.args, signal, - agentEventSink # serves as onPartialResult callback + eventSink # serves as onPartialResult callback ) return executedOutcome(result, false) ``` @@ -698,7 +698,7 @@ function executeToolCalls( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::Union{Nothing, abortSignal}, - agentEventSink, + eventSink, )::agentToolCallBatch ``` @@ -733,12 +733,12 @@ function executeToolCallsSequential(...)::agentToolCallBatch messages = toolResultMessage[] for tc in toolCalls - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) else - executed = executePreparedToolCall(prep, signal, agentEventSink) + executed = executePreparedToolCall(prep, signal, eventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) end @@ -763,14 +763,14 @@ function executeToolCallsParallel(...)::agentToolCallBatch entries = union{finalizedOutcome, task{finalizedOutcome}}[] for tc in toolCalls - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) push!(entries, finalized) # immediate outcome — no task else task = task() do - executed = executePreparedToolCall(prep, signal, agentEventSink) + executed = executePreparedToolCall(prep, signal, eventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) return finalized end @@ -845,7 +845,7 @@ From the type documentation (`type.jl:803-815`): **Source:** `agentCore.jl:266-307` ```julia -batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) +batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink) # Save results to conversation history for tool_result in batch.messages @@ -1037,13 +1037,13 @@ end ### Event Sink -The `agentEventSink` function is passed through the entire call chain: +The `eventSink` function is passed through the entire call chain: ```julia -agentEventSink = agent.agentEventSink # set during yiemAgent construction +eventSink = agent.eventSink # set during yiemAgent construction ``` -The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by: +The `eventSink` function is a user-provided callback that receives all events. This is typically used by: - **TUI (Terminal UI):** Display real-time progress, tool names, results - **Logging systems:** Record tool execution history - **Monitoring:** Track tool usage, execution times, error rates @@ -1057,12 +1057,12 @@ The `agentEventSink` function is a user-provided callback that receives all even | Hook | Signature | Called | Purpose | |------|-----------|--------|---------| -| `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | -| `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format | +| `prepareContext` | `(state::agentState, eventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | +| `formatMsgForLLM` | `(ctx::agentContext, eventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | | `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | -| `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | +| `eventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | ### `beforeToolCall` Hook @@ -1125,7 +1125,7 @@ end **Source:** `utils.jl:111-125` ```julia -function prepareContext(state::agentState, agentEventSink)::agentContext +function prepareContext(state::agentState, eventSink)::agentContext # TODO: filter tools from state.tools based on user intent filteredTools = state.tools @@ -1152,7 +1152,7 @@ end Default implementation converts `agentContext` to OpenAI-compatible format: ```julia -function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any} messages = Vector{Dict{String, Any}}() # System prompt as system message @@ -1284,7 +1284,7 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL │ Step 6: Execute tool calls │ context = agentContext(systemPrompt, messages, tools) │ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") - │ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) + │ batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink) LOOP ITERATION 1 — executeToolCallsSequential @@ -1299,7 +1299,7 @@ LOOP ITERATION 1 — executeToolCallsSequential │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) │ │ EXECUTE: - │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink) + │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, eventSink) │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) │ → executedOutcome(result, false) │ @@ -1365,20 +1365,20 @@ end ```julia # Argument preparation (before validation) -function PrepareArguments(args::Dict{String,Any})::Dict{String,Any} +function PrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any} # Return modified args, or args unchanged return args end # Custom validation (before execution) -function ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function ValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String} # Return nothing to pass, or error string to fail return nothing end # Core execution function Execute(toolCallId::String, - args::Dict{String,Any}, + args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult # Return agentToolResult with content, details, usage, terminate @@ -1400,17 +1400,17 @@ function helper_function(...) end # Optional: prepareArguments -function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any} +function myToolPrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any} return args end # Optional: validateRequiredArgs -function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function myToolValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String} return nothing end # Required: execute function -function myToolExecute(toolCallId::String, args::Dict{String,Any}, +function myToolExecute(toolCallId::String, args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult ... @@ -1481,7 +1481,7 @@ To add a new tool (e.g., `searchWine.jl`): using .type # using AdditionalPkg # add if needed -function searchWineExecute(toolCallId::String, args::Dict{String,Any}, +function searchWineExecute(toolCallId::String, args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, onPartialResult) query = get(args, "query", "") result = search_wine_db(query) diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 6d40a85..372f80b 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -1,10 +1,7 @@ module YiemAgent - export register_all_tools - - """ Order by dependencies of each file. The 1st included file must not depend on any other - files and each file can only depend on the file included before it. - """ + """Order by dependencies of each file. The 1st included file must not depend on any other + files and each file can only depend on the file included before it.""" include("type.jl") using .type @@ -15,12 +12,6 @@ module YiemAgent include("toolRegistry.jl") using .toolRegistry - function register_all_tools(store::toolRegistry.toolStore, mcpserver=nothing) - # Only register listTools — all other tools come from MCP server at runtime - registerTool(store, listTool(store, mcpserver)) - return store.tools - end - # include("llmfunction.jl") # using .llmfunction @@ -31,10 +22,10 @@ module YiemAgent using .api + # ---------------------------------------------- 100 --------------------------------------------- # - end # module YiemAgent_v1 diff --git a/src/agentCore.jl b/src/agentCore.jl index 4048e46..15b32d3 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -9,11 +9,6 @@ using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serializ using GeneralUtils using ..type, ..utils, ..toolRegistry -function register_all_tools(store::toolRegistry.toolStore, mcpServer=nothing) - # Call parent module's version which has access to tool functions - parentmodule(@__MODULE__).register_all_tools(store, mcpServer) -end - # ---------------------------------------------- 100 --------------------------------------------- # """ @@ -74,7 +69,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # return incoming_env["payloads"][1][2] # end # - # function (c::MyMCPClient)(method::String, toolName::String, arguments::Dict{String,Any}) + # function (c::MyMCPClient)(method::String, toolName::String, arguments::AbstractDict{String, Any}) # payload = Dict("jsonrpc" => "2.0", "id" => 2, "method" => method, # "params" => Dict("name" => toolName, "arguments" => arguments)) # payloads = [("payload", payload, "dictionary"),] @@ -137,7 +132,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false - agentEventSink # agent emits its status via this function + eventSink # agent emits its status via this function end """ @@ -161,7 +156,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) -- `agentEventSink::Function`: Callback to receive agent events +- `eventSink::Function`: Callback to receive agent events - `mcpServer`: Callable struct for MCP server communication. Called as `mcpServer("tools/list")` to discover tools, or `mcpServer("tools/call", args)` to execute a tool. Returns parsed JSON dicts. (default: `nothing`) @@ -184,7 +179,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink=agentEventSink, + eventSink=eventSink, mcpServer=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) @@ -194,7 +189,7 @@ function yiemAgent( # load tools (statically registered at module init) toolStore1 = toolStore(name="myagent") - register_all_tools(toolStore1, mcpServer) + registerAllTools(toolStore1, mcpServer; eventSink=eventSink) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( @@ -214,7 +209,7 @@ function yiemAgent( sessionId, maxRetryDelayMs, parallelToolExecute, - agentEventSink, + eventSink, ) # Spawn the background loop and attach it @@ -289,18 +284,18 @@ function _agentLoop(agent::yiemAgent) while true while newUserMsg === nothing if isready(agent.inputChannel) - agent.agentEventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))") # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) - agent.agentEventSink("new user msg") + agent.eventSink("new user msg") else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.agentEventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))") # if agent runs is done but followUpChannel has messages, # put new message in inputChannel instead if isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))") while isready(agent.followUpChannel) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) @@ -308,18 +303,18 @@ function _agentLoop(agent::yiemAgent) processingTask = nothing # reset result = nothing # reset else # _processMessage() done and no followUp message. - agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") result = deepcopy(agent._state.messages[end]) - agent.agentEventSink("_agentLoop 4-1 ") + agent.eventSink("_agentLoop 4-1 ") # filter out reasoningContent in-place filter!(c -> !(c isa reasoningContent), result.content) - agent.agentEventSink("_agentLoop 5 ") + agent.eventSink("_agentLoop 5 ") # format output respondToUI = _assistantMessageToOpenAI(result) - agent.agentEventSink("_agentLoop 6 ") + agent.eventSink("_agentLoop 6 ") put!(agent.outputChannel, respondToUI) if !isempty(result.content) && result.content[1] isa textContent - agent.agentEventSink(result.content[1].text) + agent.eventSink(result.content[1].text) end processingTask = nothing # reset result = nothing # reset @@ -350,7 +345,7 @@ function _agentLoop(agent::yiemAgent) else # spawn new _processMessage() if it is not already running. if processingTask === nothing - agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") # discard all messages in followUpChannel while isready(agent.followUpChannel) _ = take!(agent.followUpChannel) @@ -359,7 +354,7 @@ function _agentLoop(agent::yiemAgent) # Dispatch message through the processing pipeline processingTask = @spawn _processMessage( processMessageInputCh, - agent.agentEventSink, + agent.eventSink, agent._state.messages, agent._state.systemPrompt, agent._state.tools, @@ -381,7 +376,7 @@ function _agentLoop(agent::yiemAgent) showerror(io, e, bt) println(io) end - agentEventSink(errMsg) + eventSink(errMsg) # On any error, send error response and exit the loop @error "Agent loop failed" error=e @@ -418,7 +413,7 @@ julia> # Currently returns a placeholder echo response """ function _processMessage( inputChannel::Channel, - agentEventSink, + eventSink, agentMsgHistory::Vector{agentMessage}, systemPrompt::String, tools::OrderedDict{String, agentTool}, @@ -429,7 +424,7 @@ function _processMessage( afterToolCall::Union{Function, Nothing}, parallelToolExecute::Bool, )::Nothing - agentEventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") # loop until llmCall() response didn't use tool calls final_response = nothing @@ -449,43 +444,43 @@ function _processMessage( while true # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) - agentEventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") newUserMsg_openai = take!(inputChannel) - agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") if newUserMsg_openai === :shutdown - agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end - agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") newUserMsg = OpenAiToUserMessage(newUserMsg_openai) push!(agentMsgHistory, newUserMsg) - agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") end - agentEventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") # call prepareContext() state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") - preparedContext = prepareContext(state, agentEventSink, llmCall) - agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") + preparedContext = prepareContext(state, eventSink, llmCall) + eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") # Call formatMessagesForLLM() to format for LLM - formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) + formattedMessages = formatMessagesForLLM(preparedContext, eventSink) - agentEventSink("_processMessage 10 formattedMessages $formattedMessages") + eventSink("_processMessage 10 formattedMessages $formattedMessages") """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ response = llmCall(formattedMessages) - agentEventSink(" llmCall " * string(response)) + eventSink(" llmCall " * string(response)) - agentEventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") - agentEventSink("assistant_msg " * string(assistant_msg)) - agentEventSink("_processMessage 11-1") + eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") + eventSink("assistant_msg " * string(assistant_msg)) + eventSink("_processMessage 11-1") # Add assistant message (tool calls or text) to history for next LLM turn push!(agentMsgHistory, assistant_msg) @@ -501,21 +496,21 @@ function _processMessage( ) signal = abortSignal(false) - agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") # call executeToolCalls() toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, - signal, agentEventSink) - agentEventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") + signal, eventSink) + eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") # save toolResults to messages for toolResult in toolResultBatch.messages - agentEventSink("toolResult " * string(toolResult)) + eventSink("toolResult " * string(toolResult)) push!(agentMsgHistory, toolResult) end - agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") if toolResultBatch.terminate - agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] for toolResult in toolResultBatch.messages @@ -529,7 +524,7 @@ function _processMessage( end end end - agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") final_response = assistantMessage( role="assistant", content=final_content, @@ -548,12 +543,12 @@ function _processMessage( break end else - agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") # LLM did not use tool calls — break end end - agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") return nothing end @@ -991,48 +986,48 @@ function prepareToolCall( toolCall::agentToolCall, config::agentLoopConfig, signal::abortSignal, - agentEventSink + eventSink )::Union{preparedToolCall,immediateOutcome} - agentEventSink("prepareToolCall 1") + eventSink("prepareToolCall 1") # pick a called tool from tool store tool = get(context.tools, toolCall.name, nothing) if tool === nothing - agentEventSink("prepareToolCall 2") + eventSink("prepareToolCall 2") return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end try - agentEventSink("prepareToolCall 3") + eventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) - agentEventSink("prepared " * string(prepared.arguments)) - agentEventSink("prepareToolCall 4") + eventSink("prepared " * string(prepared.arguments)) + eventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) - agentEventSink("validatedArgs " * string(validatedArgs)) - agentEventSink("prepareToolCall 5") + eventSink("validatedArgs " * string(validatedArgs)) + eventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing - agentEventSink("prepareToolCall 6") + eventSink("prepareToolCall 6") before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) - agentEventSink("prepareToolCall 7") + eventSink("prepareToolCall 7") if signal.aborted - agentEventSink("prepareToolCall 8") + eventSink("prepareToolCall 8") return immediateOutcome(createErrorToolResult("Operation aborted"), true) end if before !== nothing && before.block - agentEventSink("prepareToolCall 9") + eventSink("prepareToolCall 9") return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end - agentEventSink("prepareToolCall 10") + eventSink("prepareToolCall 10") return preparedToolCall(tool, toolCall, validatedArgs) catch e bt = catch_backtrace() @@ -1041,7 +1036,7 @@ function prepareToolCall( println(io) end - agentEventSink(errMsg) + eventSink(errMsg) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -1091,16 +1086,16 @@ executePreparedToolCall(prep, nothing, emit) function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, - agentEventSink, + eventSink, llmCall::Union{Any,Nothing}=nothing, )::executedOutcome - agentEventSink("executePreparedToolCall 1") + eventSink("executePreparedToolCall 1") try #WORKING - result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink, llmCall) - agentEventSink("executePreparedToolCall 2") - agentEventSink(result.content[1].text) - agentEventSink("executePreparedToolCall 3") + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink, llmCall) + eventSink("executePreparedToolCall 2") + eventSink(result.content[1].text) + eventSink("executePreparedToolCall 3") return executedOutcome(result, false) catch e bt = catch_backtrace() @@ -1108,7 +1103,7 @@ function executePreparedToolCall( showerror(io, e, bt) println(io) end - agentEventSink(errMsg) + eventSink(errMsg) return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -1178,19 +1173,19 @@ function finalizeExecutedToolCall( executed::executedOutcome, config::agentLoopConfig, signal::Union{Nothing,abortSignal}, - agentEventSink + eventSink )::finalizedOutcome - agentEventSink("finalizeExecutedToolCall 1") + eventSink("finalizeExecutedToolCall 1") result = executed.result isError = executed.isError - agentEventSink("finalizeExecutedToolCall 2") + eventSink("finalizeExecutedToolCall 2") if config.afterToolCall !== nothing try after = config.afterToolCall( afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) - agentEventSink("finalizeExecutedToolCall 3") + eventSink("finalizeExecutedToolCall 3") if after !== nothing result = merge(result, dict(:content=>get(after,:content,result.content), :details=>get(after,:details,result.details), @@ -1204,13 +1199,13 @@ function finalizeExecutedToolCall( showerror(io, e, bt) println(io) end - agentEventSink(errMsg) + eventSink(errMsg) result = createErrorToolResult(sprint(showerror, e)) isError = true end end - agentEventSink("finalizeExecutedToolCall 4") + eventSink("finalizeExecutedToolCall 4") return finalizedOutcome(prep.toolCall, result, isError) end @@ -1274,41 +1269,41 @@ function executeToolCallsSequential( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, - agentEventSink, + eventSink, )::agentToolCallBatch llmCall = config.llmCall - agentEventSink("executeToolCallsSequential 1") + eventSink("executeToolCallsSequential 1") finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] for tc in toolCalls - agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) - agentEventSink("executeToolCallsSequential " * string(prep.args)) + eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) + eventSink("executeToolCallsSequential " * string(prep.args)) if prep isa immediateOutcome - agentEventSink("executeToolCallsSequential 2-1") + eventSink("executeToolCallsSequential 2-1") finalized = finalizedOutcome(tc, prep.result, prep.isError) - agentEventSink("executeToolCallsSequential 2-2") + eventSink("executeToolCallsSequential 2-2") else - agentEventSink("executeToolCallsSequential 3") - executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall) - agentEventSink("executeToolCallsSequential 3-1") + eventSink("executeToolCallsSequential 3") + executed = executePreparedToolCall(prep, signal, eventSink, llmCall) + eventSink("executeToolCallsSequential 3-1") finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, - signal, agentEventSink) - agentEventSink("executeToolCallsSequential 3-2") + signal, eventSink) + eventSink("executeToolCallsSequential 3-2") end - agentEventSink("executeToolCallsSequential 4") - agentEventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), + eventSink("executeToolCallsSequential 4") + eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), $(finalized.result), $(finalized.isError)") push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) - agentEventSink("executeToolCallsSequential 5") + eventSink("executeToolCallsSequential 5") if signal !== nothing && signal.aborted break end end - agentEventSink("executeToolCallsSequential 6") + eventSink("executeToolCallsSequential 6") return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end @@ -1375,27 +1370,27 @@ function executeToolCallsParallel( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, - agentEventSink, + eventSink, )::agentToolCallBatch entries = Union{finalizedOutcome,Task}[] llmCall = config.llmCall for tc in toolCalls - agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) - agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) push!(entries, finalized) else t = Task() do - executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall) + executed = executePreparedToolCall(prep, signal, eventSink, llmCall) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) return finalized end @@ -1478,11 +1473,11 @@ function executeToolCalls( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, - agentEventSink, + eventSink, )::agentToolCallBatch llmCall = config.llmCall - agentEventSink("_executeToolCalls 1") + eventSink("_executeToolCalls 1") hasSequential = false for tc in toolCalls t = get(context.tools, tc.name, nothing) @@ -1491,15 +1486,15 @@ function executeToolCalls( break end end - agentEventSink("_executeToolCalls 2") + eventSink("_executeToolCalls 2") if config.toolExecution == "sequential" || hasSequential - agentEventSink("_executeToolCalls 3") + eventSink("_executeToolCalls 3") return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, - agentEventSink) + eventSink) else - agentEventSink("_executeToolCalls 4") + eventSink("_executeToolCalls 4") return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, - agentEventSink) + eventSink) end end diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 8381d2e..105cfc5 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,6 +1,6 @@ module toolRegistry -export toolStore, registerTool, getTools, clearTools, listTool +export toolStore, registerTool, registerAllTools, getTools, clearTools, listTool using Dates using JSON, DataStructures @@ -9,8 +9,7 @@ using ..type """ Per-agent isolated tool storage. -Each agent gets its own `toolStore` so tool registration is independent — -`registerTool(store, tool)` only affects that agent's tool set. +Each agent gets its own `toolStore` so tool registration is independent. # Fields - `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration @@ -28,12 +27,6 @@ Create a new empty tool store. # Keyword Arguments - `name::String`: Display name for logging (default: `"default"`) - -# Example -```julia -julia> store = toolStore(name="agent1") -toolStore(OrderedDict{String, agentTool}(), "agent1") -``` """ function toolStore(; name::String="default")::toolStore toolStore(OrderedDict{String, agentTool}(), name) @@ -46,8 +39,6 @@ Extract text from MCP tool result content array. Handles JSON-RPC 2.0 result content format: {"content": [{"type": "text", "text": "..."}], "isError": false} - -Returns the joined text content, or JSON-serialized fallback if no text blocks found. """ function _extract_text_content(result::Dict)::String content = get(result, "content", Any[]) @@ -69,10 +60,9 @@ end Wrap an MCP tool definition as an `agentTool`. The returned tool's `execute` function calls the MCP server's "tools/call" -method with the validated arguments. Responses are in JSON-RPC 2.0 format -with result/error envelopes handled by the execute function. +method with the validated arguments. """ -function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool +function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing)::agentTool name = tool_def["name"] title = get(tool_def, "title", get(tool_def, "label", name)) desc = get(tool_def, "description", "") @@ -98,9 +88,9 @@ function _wrap_mcp_tool(mcpserver, tool_def::Dict{String,Any})::agentTool label=title, description=desc, inputSchema=params, - execute=(toolCallId::String, args::Dict{String,Any}, + execute=(toolCallId::String, args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, - agentEventSink, + eventSink, llmCall=nothing) -> begin try response = mcpserver("tools/call", name, args) @@ -145,14 +135,18 @@ Discover and register MCP tools into `store.tools`. Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format), parses the response envelope, and registers each discovered tool. -Handles pagination via `nextCursor` — keeps fetching until cursor is empty. -Skips tools already registered. Returns `(new_count, tool_list_text)`. +Handles pagination via `nextCursor`. Skips tools already registered. + +# Returns +- `Int`: number of new tools registered """ -function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} +function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int if mcpserver === nothing - return (0, "") + return 0 end + new_count = 0 + try response = mcpserver("tools/list") @@ -160,7 +154,8 @@ function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} if haskey(response, "error") rpc_error = response["error"] err_msg = get(rpc_error, "message", "Unknown MCP error") - return (0, "MCP tools/list failed: $err_msg") + println("[toolRegistry:$(store.name)] MCP tools/list failed: $err_msg") + return 0 end if haskey(response, "result") response = response["result"] @@ -169,13 +164,13 @@ function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} tools_array = response["tools"] cursor = get(response, "nextCursor", nothing) - new_count = 0 for tool_def in tools_array name = tool_def["name"] if haskey(store.tools, name) continue end - wrapped = _wrap_mcp_tool(mcpserver, tool_def) + @show "tool_def $(typeof(tool_def))" + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink) store.tools[name] = wrapped new_count += 1 end @@ -193,101 +188,29 @@ function _register_mcp_tools(mcpserver, store::toolStore)::Tuple{Int, String} if haskey(store.tools, name) continue end - wrapped = _wrap_mcp_tool(mcpserver, tool_def) + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink) store.tools[name] = wrapped new_count += 1 end end - # Build readable tool list - lines = String[ - "- $(t.name): $(t.label) — $(t.description)" - for (k, t) in store.tools - ] - tool_list_text = "Discovered $(new_count) MCP tools. Total registered: $(length(store.tools)).\nAvailable tools:\n" * join(lines, "\n") - - return (new_count, tool_list_text) + println("[toolRegistry:$(store.name)] Discovered $new_count MCP tools. Total: $(length(store.tools))") catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + eventSink(err_msg) + errMsg = sprint(showerror, e) - return (0, "MCP tools/list failed: $errMsg") + println("[toolRegistry:$(store.name)] MCP tools/list failed: $errMsg") end + + return new_count end -""" - listTool(store::toolStore, mcpserver) -> agentTool - -MCP-aware listTools tool (JSON-RPC 2.0 protocol). - -First call: queries the MCP server via `mcpserver("tools/list")`, registers -all discovered tools into the shared `store.tools` (in-place mutation, with -pagination via nextCursor), then returns the full tool list. - -Subsequent calls: returns the current list (tools remain registered). - -This is the only pre-registered tool. All other tools come from the -MCP server and are loaded at runtime when the LLM calls listTools(). - -# Arguments -- `store`: The tool store to populate with MCP tools -- `mcpserver`: A callable struct that communicates with the MCP server. - Called as `mcpserver("tools/list")` or `mcpserver("tools/call", args)`. - Returns parsed JSON dicts. - -# Example -```julia -# User provides an MCP server client (callable struct) -mcp = MyMCPClient("nats://localhost:4222") -store = toolStore(name="agent1") -registerTool(store, listTool(store, mcp)) - -# When agent calls listTools(), tools are discovered from MCP server -# and registered into store.tools in real time. -``` -""" -function listTool(store::toolStore, mcpserver)::agentTool - return agentTool( - name="listTools", - label="List Tools", - description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.", - inputSchema=Dict{String,Any}( - "type" => "object", - "properties" => Dict{String,Any}(), - "required" => Any[] - ), - execute=(toolCallId::String, args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - agentEventSink, llmCall=nothing) -> begin - # Discover and register MCP tools (idempotent — skips already registered) - new_count, tool_list = _register_mcp_tools(mcpserver, store) - - # Always include listTools itself in the count - total = length(store.tools) - - if new_count > 0 - result_text = tool_list - else - # Already discovered — just return current list - lines = String[ - "- $(t.name): $(t.label) — $(t.description)" - for (k, t) in store.tools - ] - result_text = "Available tools ($total):\n" * join(lines, "\n") - end - - return agentToolResult( - [textContent(result_text)], - Dict{Any,Any}("count" => total), - nothing, false - ) - end, - prepareArguments=nothing, - validateRequiredArgs=nothing, - parallelToolExecute=false, - ) -end - -# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope - """ registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} @@ -299,18 +222,9 @@ Add `tool` to `store`, overwriting any existing tool with the same name. # Returns - The same `store.tools` dict (modified in place) - -# Example -```julia -julia> store = toolStore(name="agent1"); - -julia> registerTool(store, listTool(store, nothing)) -[toolRegistry:agent1] Registered tool: listTools -OrderedDict{String, agentTool} with 1 entry: - "listTools" => agentTool(...) -``` """ -function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool} +function registerTool(store::toolStore, tool::agentTool; eventSink=nothing + )::OrderedDict{String, agentTool} store.tools[tool.name] = tool println("[$(store.name)] Registered tool: $(tool.name)") return store.tools @@ -319,22 +233,13 @@ end """ Return the tools registered in `store`. -The returned dict is the **same object** stored inside `store` — mutations -to it (e.g. via `registerTool`) are visible through subsequent calls. +The returned dict is the **same object** stored inside `store`. # Arguments - `store`: Tool store to query # Returns - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order - -# Example -```julia -julia> tools = getTools(store) -OrderedDict{String, agentTool} with 2 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) -``` """ function getTools(store::toolStore)::OrderedDict{String, agentTool} return store.tools @@ -348,16 +253,6 @@ Remove all tools from `store`. # Returns - `nothing` - -# Example -```julia -julia> clearTools(store) -[toolRegistry:agent1] Registry cleared -nothing - -julia> getTools(store) -OrderedDict{String, agentTool} with 0 entries -``` """ function clearTools(store::toolStore)::Nothing empty!(store.tools) @@ -365,4 +260,76 @@ function clearTools(store::toolStore)::Nothing return nothing end +# ── listTools tool (auto-discover new MCP tools at runtime) ───────── + +""" + listTool(store::toolStore, mcpserver) -> agentTool + +MCP-aware listTools tool (JSON-RPC 2.0 protocol). + +First call: queries the MCP server via `mcpserver("tools/list")`, registers +all discovered tools into the shared `store.tools` (in-place mutation, with +pagination via nextCursor), then returns the full tool list. + +Subsequent calls: returns the current list (tools remain registered). +""" +function listTool(store::toolStore, mcpserver)::agentTool + return agentTool( + name="listTools", + label="List Tools", + description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.", + inputSchema=Dict{String,Any}( + "type" => "object", + "properties" => Dict{String,Any}(), + "required" => Any[] + ), + execute=(toolCallId::String, args::AbstractDict{String, Any}, + signal::Union{Nothing,abortSignal}, + eventSink, llmCall=nothing) -> begin + # Discover and register MCP tools (idempotent — skips already registered) + new_count = register_mcp_tools(mcpserver, store) + + # Always include listTools itself in the count + total = length(store.tools) + + lines = String[ + "- $(t.name): $(t.label) — $(t.description)" + for (k, t) in store.tools + ] + result_text = "Available tools ($total):\n" * join(lines, "\n") + + return agentToolResult( + [textContent(result_text)], + Dict{Any,Any}("count" => total), + nothing, false + ) + end, + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, + ) +end + +# ── High-level API ────────────────────────────────────────────────── + +""" + registerAllTools(store::toolStore, mcpserver) -> toolStore + +Register all tools for an agent: + 1. Auto-discover existing tools from the MCP server + 2. Register the `listTools` tool so the agent can discover new tools at runtime + +# Arguments +- `store`: The tool store to populate +- `mcpserver`: A callable struct that communicates with the MCP server + +# Returns +- The populated `toolStore` +""" +function registerAllTools(store::toolStore, mcpserver=nothing; eventSink=nothing)::toolStore + register_mcp_tools(mcpserver, store; eventSink=eventSink) + registerTool(store, listTool(store, mcpserver); eventSink=eventSink) + return store +end + end # module diff --git a/src/type.jl b/src/type.jl index 0411748..0760cd9 100644 --- a/src/type.jl +++ b/src/type.jl @@ -487,13 +487,13 @@ Context passed to the `beforeToolCall` hook. # Arguments - `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call being prepared -- `args::Dict{String,Any}`: Validated tool arguments +- `args::AbstractDict{String, Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ struct beforeToolCallContext message::assistantMessageToolCall toolCall::agentToolCall - args::Dict{String,Any} + args::AbstractDict{String, Any} context::agentContext end @@ -508,7 +508,7 @@ Context passed to the `afterToolCall` hook. # Arguments - `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call that was executed -- `args::Dict{String,Any}`: Tool arguments +- `args::AbstractDict{String, Any}`: Tool arguments - `result::agentToolResult`: The raw tool result - `isError::Bool`: Whether execution resulted in an error - `context::agentContext`: Current conversation context @@ -516,7 +516,7 @@ Context passed to the `afterToolCall` hook. struct afterToolCallContext message::assistantMessageToolCall toolCall::agentToolCall - args::Dict{String,Any} + args::AbstractDict{String, Any} result::agentToolResult isError::Bool context::agentContext @@ -528,12 +528,12 @@ Event emitted when a tool call execution starts. # Arguments - `toolCallId::String`: ID of the tool call - `toolName::String`: Name of the tool -- `arguments::Dict{String,Any}`: Tool arguments +- `arguments::AbstractDict{String, Any}`: Tool arguments """ struct toolExecStartEvent toolCallId::String toolName::String - arguments::Dict{String,Any} + arguments::AbstractDict{String, Any} end """ @@ -542,13 +542,13 @@ Event emitted with partial results during tool execution. # Arguments - `toolCallId::String`: ID of the tool call - `toolName::String`: Name of the tool -- `arguments::Dict{String,Any}`: Tool arguments +- `arguments::AbstractDict{String, Any}`: Tool arguments - `partialResult::Any`: The partial result data """ struct toolExecUpdateEvent toolCallId::String toolName::String - arguments::Dict{String,Any} + arguments::AbstractDict{String, Any} partialResult::Any end diff --git a/src/utils.jl b/src/utils.jl index d7aa1fd..57d9e59 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,7 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, - beforeToolCall, afterToolCall, agentEventSink + beforeToolCall, afterToolCall, eventSink using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils @@ -109,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages) # end ``` """ -function prepareContext(state::agentState, agentEventSink, llmCall=nothing)::agentContext +function prepareContext(state::agentState, eventSink, llmCall=nothing)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools @@ -156,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [ ]) ``` """ -function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any} """ openai message format example msg = Dict( @@ -208,7 +208,7 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} openaiReadyMsg = Dict{String, Any}() # openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL" messages = Vector{Dict{String, Any}}() - agentEventSink("formatMsgForLLM 1") + eventSink("formatMsgForLLM 1") # System prompt as system message if !isempty(ctx.systemPrompt) push!(messages, Dict( @@ -216,7 +216,7 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] )) end - agentEventSink("formatMsgForLLM 2") + eventSink("formatMsgForLLM 2") # Conversation messages for msg in ctx.messages if msg isa userMessage @@ -229,10 +229,10 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} push!(messages, _toolResultMessageToOpenAI(msg)) end end - agentEventSink("formatMsgForLLM 3") + eventSink("formatMsgForLLM 3") # Convert ctx.tools into OpenAI tools format - tools_array = _toolsToOpenAI(ctx.tools, agentEventSink) - agentEventSink("formatMsgForLLM 4") + tools_array = _toolsToOpenAI(ctx.tools, eventSink) + eventSink("formatMsgForLLM 4") openaiReadyMsg["messages"] = messages openaiReadyMsg["temperature"] = 0.7 @@ -321,7 +321,7 @@ end #TODO -function agentEventSink(x) +function eventSink(x) end @@ -375,7 +375,7 @@ function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{ ) end -""" +""" Convert an assistantMessage to OpenAI message format. """ function _assistantMessageToOpenAI(msg::assistantMessage)::Dict{String, Any} @@ -437,10 +437,10 @@ _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}} +function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, eventSink)::Vector{Dict{String, Any}} tools_array = Vector{Dict{String, Any}}() - agentEventSink("_toolsToOpenAI 1") - agentEventSink(string(typeof(tools))) + eventSink("_toolsToOpenAI 1") + eventSink(string(typeof(tools))) if tools !== nothing for (_, tool) in tools push!(tools_array, Dict( @@ -453,13 +453,13 @@ function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, a )) end end - agentEventSink("_toolsToOpenAI 2") + eventSink("_toolsToOpenAI 2") return tools_array end """ - validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String} + validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{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 @@ -470,8 +470,8 @@ 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) +- `args::AbstractDict{String, Any}`: The arguments provided by the LLM +- `inputSchema::AbstractDict{String, Any}`: The tool's `inputSchema` (JSON Schema format) # Returns - `nothing` if all required args are present @@ -487,7 +487,7 @@ args2 = Dict("city" => "Tokyo") validateRequiredArgs(args2, schema) # => nothing ``` """ -function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String} +function validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing,String} required = get(inputSchema, "required", Any[]) if isempty(required) return nothing @@ -541,7 +541,7 @@ validateToolArguments(toolWithHook, tc) # => validated args or throws validateToolArguments(toolDefault, tc) # => args or throws ``` """ -function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{String,Any} +function validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{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) diff --git a/user_code.jl b/user_code.jl index 2123d8a..54ad652 100644 --- a/user_code.jl +++ b/user_code.jl @@ -32,7 +32,7 @@ catch e println(io) end - agentEventSink(err_msg) + eventSink(err_msg) end """ @@ -66,13 +66,13 @@ end -struct agentEventSink +struct eventSink natsConn::NATS.Connection topic::String senderID::String end -function (aes::agentEventSink)(msg::String) +function (aes::eventSink)(msg::String) NATS.publish(aes.natsConn, aes.topic, msg) end @@ -88,11 +88,11 @@ text2text_llm = text2textInstructLLM(agent_conn, "sender", config["externalservice"]["fileserver"]["url"]) -debugNats = agentEventSink(agent_conn, "sommanion.debug", "sender") +debugNats = eventSink(agent_conn, "sommanion.debug", "sender") agent = YiemAgent.yiemAgent( text2text_llm; - agentEventSink=debugNats + eventSink=debugNats ) msg = Dict( -- 2.52.0 From d2592ab6d67a2e33928f4b39b24d48e807ea2fa5 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 21 Aug 2026 22:34:27 +0700 Subject: [PATCH 098/112] mcp works --- src/agentCore.jl | 210 +++++++++++++++++++++++--------------------- src/api.jl | 12 +-- src/toolRegistry.jl | 122 +++++++++---------------- src/type.jl | 6 +- src/utils.jl | 8 +- 5 files changed, 161 insertions(+), 197 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 15b32d3..45a4738 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -187,13 +187,13 @@ function yiemAgent( followUp = Channel(32) outputChannel = Channel(16) - # load tools (statically registered at module init) - toolStore1 = toolStore(name="myagent") - registerAllTools(toolStore1, mcpServer; eventSink=eventSink) + + tools = OrderedDict{String, agentTool}() + registerAllTools(tools, mcpServer; eventSink=eventSink) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( - agentState(systemPrompt, model, getTools(toolStore1), messages), + agentState(systemPrompt, model, tools, messages), inputChannel, followUp, outputChannel, @@ -442,112 +442,123 @@ function _processMessage( """ while true - # Drain inputChannel and convert OpenAI-format messages to userMessage type - while isready(inputChannel) - eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") - newUserMsg_openai = take!(inputChannel) - eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") - if newUserMsg_openai === :shutdown - eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") - # Re-emit shutdown signal for the loop to handle - put!(inputChannel, :shutdown) - break + try + # Drain inputChannel and convert OpenAI-format messages to userMessage type + while isready(inputChannel) + eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") + newUserMsg_openai = take!(inputChannel) + eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") + if newUserMsg_openai === :shutdown + eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") + # Re-emit shutdown signal for the loop to handle + put!(inputChannel, :shutdown) + break + end + eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") + newUserMsg = OpenAiToUserMessage(newUserMsg_openai) + push!(agentMsgHistory, newUserMsg) + eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") end - eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") - newUserMsg = OpenAiToUserMessage(newUserMsg_openai) - push!(agentMsgHistory, newUserMsg) - eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") - end - eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") - # call prepareContext() - state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") - preparedContext = prepareContext(state, eventSink, llmCall) - eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") - # Call formatMessagesForLLM() to format for LLM - formattedMessages = formatMessagesForLLM(preparedContext, eventSink) - - eventSink("_processMessage 10 formattedMessages $formattedMessages") + eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") + # call prepareContext() + state = agentState(systemPrompt, nothing, tools, agentMsgHistory) + eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") + preparedContext = prepareContext(state, eventSink) + eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") + # Call formatMessagesForLLM() to format for LLM + formattedMessages = formatMessagesForLLM(preparedContext, eventSink) + + eventSink("_processMessage 10 formattedMessages $formattedMessages") - """ response example - response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) - """ - response = llmCall(formattedMessages) - eventSink(" llmCall " * string(response)) + """ response example + response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) + """ + response = llmCall(formattedMessages) + eventSink("LLM response " * string(response)) - eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") - # Extract tool calls from LLM response content blocks - hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") - eventSink("assistant_msg " * string(assistant_msg)) - eventSink("_processMessage 11-1") + eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") + # Extract tool calls from LLM response content blocks + hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) + eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") + eventSink("assistant_msg " * string(assistant_msg)) + eventSink("_processMessage 11-1") - # Add assistant message (tool calls or text) to history for next LLM turn - push!(agentMsgHistory, assistant_msg) + # Add assistant message (tool calls or text) to history for next LLM turn + push!(agentMsgHistory, assistant_msg) - if hasToolCalls && length(toolCallList) > 0 - # Build context and config for executeToolCalls + if hasToolCalls && length(toolCallList) > 0 + # Build context and config for executeToolCalls - config = agentLoopConfig( - beforeToolCall, - afterToolCall, - parallelToolExecute ? "parallel" : "sequential", - llmCall, - ) + config = agentLoopConfig( + beforeToolCall, + afterToolCall, + parallelToolExecute ? "parallel" : "sequential", + ) - signal = abortSignal(false) - eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") + signal = abortSignal(false) + eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") - # call executeToolCalls() - toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, - signal, eventSink) - eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") + # call executeToolCalls() + toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, + signal, eventSink) + eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") - # save toolResults to messages - for toolResult in toolResultBatch.messages - eventSink("toolResult " * string(toolResult)) - push!(agentMsgHistory, toolResult) - end - eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") - if toolResultBatch.terminate - eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") - # If toolResultBatch requested termination, build a final response - final_content = [textContent("Tool execution completed.")] + # save toolResults to messages for toolResult in toolResultBatch.messages - for content_block in toolResult.content - if content_block isa textContent - append!(final_content, [content_block]) - elseif content_block isa Dict - if haskey(content_block, :text) - push!(final_content, textContent(content_block[:text])) + eventSink("toolResult " * string(toolResult)) + push!(agentMsgHistory, toolResult) + end + eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") + if toolResultBatch.terminate + eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") + # If toolResultBatch requested termination, build a final response + final_content = [textContent("Tool execution completed.")] + for toolResult in toolResultBatch.messages + for content_block in toolResult.content + if content_block isa textContent + append!(final_content, [content_block]) + elseif content_block isa Dict + if haskey(content_block, :text) + push!(final_content, textContent(content_block[:text])) + end end end end + eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") + final_response = assistantMessage( + role="assistant", + content=final_content, + api=assistant_msg.api, + model=assistant_msg.model, + usage=assistant_msg.usage, + stopReason="tool_use_terminated", + errorMessage=if any(x -> x.isError, toolResultBatch.messages) + "One or more tool calls failed" + else + nothing + end, + timestamp=now(), + ) + push!(agentMsgHistory, final_response) + break end - eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") - final_response = assistantMessage( - role="assistant", - content=final_content, - api=assistant_msg.api, - model=assistant_msg.model, - usage=assistant_msg.usage, - stopReason="tool_use_terminated", - errorMessage=if any(x -> x.isError, toolResultBatch.messages) - "One or more tool calls failed" - else - nothing - end, - timestamp=now(), - ) - push!(agentMsgHistory, final_response) + else + eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") + # LLM did not use tool calls — break end - else - eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") - # LLM did not use tool calls — - break + catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + eventSink(err_msg) end end + + eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") return nothing end @@ -1087,14 +1098,13 @@ function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, eventSink, - llmCall::Union{Any,Nothing}=nothing, )::executedOutcome eventSink("executePreparedToolCall 1") try #WORKING - result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink, llmCall) + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink) eventSink("executePreparedToolCall 2") - eventSink(result.content[1].text) + eventSink("tool execute raw result: " * sprint(show, result)) eventSink("executePreparedToolCall 3") return executedOutcome(result, false) catch e @@ -1271,7 +1281,6 @@ function executeToolCallsSequential( signal::abortSignal, eventSink, )::agentToolCallBatch - llmCall = config.llmCall eventSink("executeToolCallsSequential 1") finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] @@ -1287,9 +1296,10 @@ function executeToolCallsSequential( eventSink("executeToolCallsSequential 2-2") else eventSink("executeToolCallsSequential 3") - executed = executePreparedToolCall(prep, signal, eventSink, llmCall) + executed = executePreparedToolCall(prep, signal, eventSink) + eventSink("executeToolCallsSequential 3-1") - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal, eventSink) eventSink("executeToolCallsSequential 3-2") end @@ -1374,7 +1384,6 @@ function executeToolCallsParallel( )::agentToolCallBatch entries = Union{finalizedOutcome,Task}[] - llmCall = config.llmCall for tc in toolCalls eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) @@ -1388,7 +1397,7 @@ function executeToolCallsParallel( push!(entries, finalized) else t = Task() do - executed = executePreparedToolCall(prep, signal, eventSink, llmCall) + executed = executePreparedToolCall(prep, signal, eventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) @@ -1476,7 +1485,6 @@ function executeToolCalls( eventSink, )::agentToolCallBatch - llmCall = config.llmCall eventSink("_executeToolCalls 1") hasSequential = false for tc in toolCalls diff --git a/src/api.jl b/src/api.jl index 50d6dbd..04812e3 100644 --- a/src/api.jl +++ b/src/api.jl @@ -18,7 +18,7 @@ The agent processes messages from `inputChannel` in the background task. # Arguments - `agent::yiemAgent`: The agent instance to send a message to -- `msg`: The message to send (any type accepted by the agent's processing pipeline) +- `msg`: The message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Hello!")`) # Returns - The same `agent` instance for chaining @@ -29,11 +29,11 @@ The agent processes messages from `inputChannel` in the background task. # Examples ```jldoctest -julia> runAgent(agent, "Hello!") +julia> runAgent(agent, Dict("role" => "user", "content" => "Hello!")) yiemAgent(...) ``` """ -function runAgent(agent::yiemAgent, msg) +function runAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) put!(agent.inputChannel, msg) return agent end @@ -70,7 +70,7 @@ and before any tool call results are sent. # Arguments - `agent::yiemAgent`: The agent instance to send a follow-up message to -- `msg`: The follow-up message to send +- `msg`: The follow-up message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Also consider red wines")`) # Returns - The same `agent` instance for chaining @@ -82,11 +82,11 @@ and before any tool call results are sent. # Examples ```jldoctest -julia> followUp(agent, "Also consider red wines") +julia> followUp(agent, Dict("role" => "user", "content" => "Also consider red wines")) yiemAgent(...) ``` """ -function followUp(agent::yiemAgent, msg) +function followUp(agent::yiemAgent, msg::AbstractDict{String, Any}) put!(agent.followUpChannel, msg) return agent end diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 105cfc5..80f6662 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,36 +1,12 @@ module toolRegistry -export toolStore, registerTool, registerAllTools, getTools, clearTools, listTool +export registerTool, registerAllTools, clearTools, listTool using Dates using JSON, DataStructures using ..type -""" - Per-agent isolated tool storage. -Each agent gets its own `toolStore` so tool registration is independent. - -# Fields -- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration -- `name::String` — identifier for debugging/logs -""" -struct toolStore - tools::OrderedDict{String, agentTool} - name::String -end - -""" - toolStore(; name="default") -> toolStore - -Create a new empty tool store. - -# Keyword Arguments -- `name::String`: Display name for logging (default: `"default"`) -""" -function toolStore(; name::String="default")::toolStore - toolStore(OrderedDict{String, agentTool}(), name) -end # ── MCP helper functions ──────────────────────────────────────────── @@ -40,7 +16,7 @@ Extract text from MCP tool result content array. Handles JSON-RPC 2.0 result content format: {"content": [{"type": "text", "text": "..."}], "isError": false} """ -function _extract_text_content(result::Dict)::String +function _extract_text_content(result::AbstractDict)::String content = get(result, "content", Any[]) if content isa Vector && !isempty(content) lines = String[] @@ -90,8 +66,7 @@ function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSin inputSchema=params, execute=(toolCallId::String, args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, - eventSink, - llmCall=nothing) -> begin + eventSink) -> begin try response = mcpserver("tools/call", name, args) @@ -140,7 +115,7 @@ Handles pagination via `nextCursor`. Skips tools already registered. # Returns - `Int`: number of new tools registered """ -function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int +function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing)::Int if mcpserver === nothing return 0 end @@ -148,13 +123,14 @@ function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int new_count = 0 try + eventSink("register_mcp_tools 1") response = mcpserver("tools/list") - + eventSink("register_mcp_tools 2") # Parse JSON-RPC 2.0 response envelope if haskey(response, "error") rpc_error = response["error"] err_msg = get(rpc_error, "message", "Unknown MCP error") - println("[toolRegistry:$(store.name)] MCP tools/list failed: $err_msg") + println("[toolRegistry] MCP tools/list failed: $err_msg") return 0 end if haskey(response, "result") @@ -166,12 +142,12 @@ function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int for tool_def in tools_array name = tool_def["name"] - if haskey(store.tools, name) + if haskey(tools, name) continue end - @show "tool_def $(typeof(tool_def))" + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink) - store.tools[name] = wrapped + tools[name] = wrapped new_count += 1 end @@ -185,16 +161,16 @@ function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int cursor = get(response, "nextCursor", nothing) for tool_def in tools_array name = tool_def["name"] - if haskey(store.tools, name) + if haskey(tools, name) continue end wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink) - store.tools[name] = wrapped + tools[name] = wrapped new_count += 1 end end - println("[toolRegistry:$(store.name)] Discovered $new_count MCP tools. Total: $(length(store.tools))") + println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(tools))") catch e bt = catch_backtrace() err_msg = sprint() do io @@ -205,75 +181,60 @@ function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int eventSink(err_msg) errMsg = sprint(showerror, e) - println("[toolRegistry:$(store.name)] MCP tools/list failed: $errMsg") + println("[toolRegistry] MCP tools/list failed: $errMsg") end return new_count end """ - registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} + registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool) -> OrderedDict{String, agentTool} -Add `tool` to `store`, overwriting any existing tool with the same name. +Add `tool` to `tools`, overwriting any existing tool with the same name. # Arguments -- `store`: Tool store to modify +- `tools`: Tool dict to modify - `tool`: The `agentTool` to register # Returns -- The same `store.tools` dict (modified in place) +- The same `tools` dict (modified in place) """ -function registerTool(store::toolStore, tool::agentTool; eventSink=nothing +function registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool; eventSink=nothing )::OrderedDict{String, agentTool} - store.tools[tool.name] = tool - println("[$(store.name)] Registered tool: $(tool.name)") - return store.tools + tools[tool.name] = tool + println("[toolRegistry] Registered tool: $(tool.name)") + return tools end """ -Return the tools registered in `store`. - -The returned dict is the **same object** stored inside `store`. +Remove all tools from the dict. # Arguments -- `store`: Tool store to query - -# Returns -- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order -""" -function getTools(store::toolStore)::OrderedDict{String, agentTool} - return store.tools -end - -""" -Remove all tools from `store`. - -# Arguments -- `store`: Tool store to clear +- `tools`: Tool dict to clear # Returns - `nothing` """ -function clearTools(store::toolStore)::Nothing - empty!(store.tools) - println("[$(store.name)] Registry cleared") +function clearTools(tools::OrderedDict{String, agentTool})::Nothing + empty!(tools) + println("[toolRegistry] Registry cleared") return nothing end # ── listTools tool (auto-discover new MCP tools at runtime) ───────── """ - listTool(store::toolStore, mcpserver) -> agentTool + listTool(tools::OrderedDict{String, agentTool}, mcpserver) -> agentTool MCP-aware listTools tool (JSON-RPC 2.0 protocol). First call: queries the MCP server via `mcpserver("tools/list")`, registers -all discovered tools into the shared `store.tools` (in-place mutation, with +all discovered tools into `tools` (in-place mutation, with pagination via nextCursor), then returns the full tool list. Subsequent calls: returns the current list (tools remain registered). """ -function listTool(store::toolStore, mcpserver)::agentTool +function listTool(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing)::agentTool return agentTool( name="listTools", label="List Tools", @@ -285,16 +246,17 @@ function listTool(store::toolStore, mcpserver)::agentTool ), execute=(toolCallId::String, args::AbstractDict{String, Any}, signal::Union{Nothing,abortSignal}, - eventSink, llmCall=nothing) -> begin + eventSink) -> begin # Discover and register MCP tools (idempotent — skips already registered) - new_count = register_mcp_tools(mcpserver, store) + new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink) + eventSink("tools dump: " * sprint(show, tools)) # Always include listTools itself in the count - total = length(store.tools) + total = length(tools) lines = String[ "- $(t.name): $(t.label) — $(t.description)" - for (k, t) in store.tools + for (k, t) in tools ] result_text = "Available tools ($total):\n" * join(lines, "\n") @@ -313,23 +275,19 @@ end # ── High-level API ────────────────────────────────────────────────── """ - registerAllTools(store::toolStore, mcpserver) -> toolStore + registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver) Register all tools for an agent: 1. Auto-discover existing tools from the MCP server 2. Register the `listTools` tool so the agent can discover new tools at runtime # Arguments -- `store`: The tool store to populate +- `tools`: The tool dict to populate - `mcpserver`: A callable struct that communicates with the MCP server - -# Returns -- The populated `toolStore` """ -function registerAllTools(store::toolStore, mcpserver=nothing; eventSink=nothing)::toolStore - register_mcp_tools(mcpserver, store; eventSink=eventSink) - registerTool(store, listTool(store, mcpserver); eventSink=eventSink) - return store +function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing) + list_t = listTool(tools, mcpserver; eventSink=eventSink) + registerTool(tools, list_t; eventSink=eventSink) end end # module diff --git a/src/type.jl b/src/type.jl index 0760cd9..013e809 100644 --- a/src/type.jl +++ b/src/type.jl @@ -358,7 +358,6 @@ 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 @@ -408,7 +407,7 @@ function agentState( agentState( systemPrompt, model, - deepcopy(tools), + tools, deepcopy(messages), Vector{String}(), nothing, @@ -443,7 +442,7 @@ end """ Configuration for the agent tool execution loop. -# Arguments +# Fields - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `toolExecution::String`: Execution mode — "sequential" or "parallel" @@ -452,7 +451,6 @@ struct agentLoopConfig beforeToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing} toolExecution::String - llmCall::Union{Any, Nothing} # LLM call function (for tools like searchWine) end """ diff --git a/src/utils.jl b/src/utils.jl index 57d9e59..515ee28 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -109,18 +109,18 @@ prepareContext(state).messages == deepcopy(state.messages) # end ``` """ -function prepareContext(state::agentState, eventSink, llmCall=nothing)::agentContext - +function prepareContext(state::agentState, eventSink)::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) + agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools) return agentCtx end -- 2.52.0 From 5f284883b317d55d5feb7e4935d3a8840eaf1333 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 21 Aug 2026 22:39:51 +0700 Subject: [PATCH 099/112] update --- README.md | 8 ++++---- README_tools.md | 6 +++--- src/YiemAgent.jl | 2 ++ src/api.jl | 10 +++++----- src/type.jl | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2f723ef..ba83600 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Julia framework for building agents with tool use and MCP (Model Context Protoco ```julia agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, eventSink=yourSink) ``` -4. Call `runAgent(agent, message)` then `takeResponse(agent)` +4. Call `promptAgent(agent, message)` then `takeResponse(agent)` ## Architecture @@ -20,7 +20,7 @@ src/ ├── type.jl # Core types (messages, tools, agent state) ├── utils.jl # Message formatting, context preparation, validation ├── agentCore.jl # Agent loop, tool execution pipeline -├── api.jl # Public API (runAgent, takeResponse, followUp, stopAgent) +├── api.jl # Public API (promptAgent, takeResponse, followUp, stopAgent) └── toolRegistry.jl # Tool store, MCP discovery, listTools registration ``` @@ -114,7 +114,7 @@ Tools are discovered dynamically via MCP server: | Function | Description | |----------|-------------| -| `runAgent(agent, msg)` | Send a message to the agent's input channel | +| `promptAgent(agent, msg)` | Send a message to the agent's input channel | | `takeResponse(agent)` | Block and take the agent's response from output channel | | `followUp(agent, msg)` | Send a follow-up message while agent is still processing | | `stopAgent(agent)` | Gracefully stop the agent and close channels | @@ -122,7 +122,7 @@ Tools are discovered dynamically via MCP server: ## Agent Lifecycle 1. `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel` -2. User messages enter via `runAgent()` or `followUp()` +2. User messages enter via `promptAgent()` or `followUp()` 3. `_processMessage()` handles LLM calls, tool execution, and conversation history 4. Tool execution follows three phases: `prepareToolCall` → `executePreparedToolCall` → `finalizeExecutedToolCall` 5. `beforeToolCall`/`afterToolCall` hooks allow pre/post-processing of tool calls diff --git a/README_tools.md b/README_tools.md index c6ab65d..fb492f1 100644 --- a/README_tools.md +++ b/README_tools.md @@ -126,7 +126,7 @@ result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) **Via agent loop (production):** ``` -user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) +user message → promptAgent(agent, Dict("role"=>"user", "content"=>...)) → _agentLoop detects message → @spawn _processMessage(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls @@ -346,7 +346,7 @@ The `_agentLoop()` function runs as a background `@spawn` task, created when `yi ``` yiemAgent struct contains: - - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() + - inputChannel (Channel, capacity 16) ← user sends messages here via promptAgent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() ``` @@ -1243,7 +1243,7 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT ``` USER SENDS MESSAGE - └─> runAgent(agent, "What's the weather in Tokyo?") + └─> promptAgent(agent, "What's the weather in Tokyo?") └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 372f80b..ccfee40 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -20,6 +20,8 @@ module YiemAgent include("api.jl") using .api + + export promptAgent, takeResponse, followUp, stopAgent diff --git a/src/api.jl b/src/api.jl index 04812e3..7bc924e 100644 --- a/src/api.jl +++ b/src/api.jl @@ -1,6 +1,6 @@ module api -export prompt +export promptAgent, takeResponse, followUp, stopAgent using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames @@ -29,11 +29,11 @@ The agent processes messages from `inputChannel` in the background task. # Examples ```jldoctest -julia> runAgent(agent, Dict("role" => "user", "content" => "Hello!")) +julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!")) yiemAgent(...) ``` """ -function runAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) +function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) put!(agent.inputChannel, msg) return agent end @@ -50,7 +50,7 @@ Blocks until the agent sends a response. - An `assistantMessage` instance representing the agent's response # Notes -- Use `runAgent(agent, msg)` to send a message before calling this function. +- Use `promptAgent(agent, msg)` to send a message before calling this function. # Examples ```jldoctest @@ -76,7 +76,7 @@ and before any tool call results are sent. - The same `agent` instance for chaining # Notes -- Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional +- Use `promptAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional messages while the agent is processing. - Follow-up messages are buffered in a separate channel (capacity 32 by default). diff --git a/src/type.jl b/src/type.jl index 013e809..ced7575 100644 --- a/src/type.jl +++ b/src/type.jl @@ -23,7 +23,7 @@ preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, agentToolCallBatch, # Functions (defined elsewhere) - runAgent, takeResponse, followUp, stopAgent + promptAgent, takeResponse, followUp, stopAgent using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads -- 2.52.0 From 91932605393a52340e96f6996bb943756ad9ef6d Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 24 Aug 2026 13:33:20 +0700 Subject: [PATCH 100/112] update --- appconfig.json | 54 --------------------- src/YiemAgent.jl | 5 +- src/agentCore.jl | 4 +- src/toolRegistry.jl | 112 ++++++++++++++++++++++---------------------- 4 files changed, 58 insertions(+), 117 deletions(-) delete mode 100644 appconfig.json diff --git a/appconfig.json b/appconfig.json deleted file mode 100644 index 60ef6f3..0000000 --- a/appconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "nats_server_info": { - "description": "nats server", - "url": "nats.yiem.cc" - }, - "testingOrProduction": "testing", - "agentId": "2b74b87a-5413-4fe2-a4d3-405891051680", - "agentCentralConfigSubject": "/yiem/hq/agent/sommelier/backend/config/api/v1.1", - "this_service_name": "agent_backend", - "this_service_input_channel": { - "mqtt": [ - "/yiem/hq/agent/sommpanion/backend/db/api_v1" - ], - "nats": [ - "sommpanion.backend.agentbackend.v1.inbox" - ] - }, - "agentRole": "sommelier", - "organization": "yiem_hq", - "externalservice": { - "servicesloadbalancer": { - "nats": "sommpanion.backend.servicesloadbalancer.v1.inbox" - }, - "textembedding": { - "url": "textembedding.api.v1" - }, - "textimage_to_text_llm": { - "url": "https://llmcoder.yiem.cc/v1/chat/completions", - "modelname": "Qwen3.6-35B-A3B-UD-Q4_K_M" - }, - "virtualWineCustomer_1": { - "serviceSubject": "", - "modelName": "qwen3:8b" - }, - "sommpanion_db" : { - "description": "A database connection info for LibPQ client", - "url": "192.168.88.106:5432", - "dbname": "winedb", - "user": "admin", - "password": "admin@Sommpanion_0.0" - }, - "sommpanion_vectordb" : { - "description": "A wine database connection info for LibPQ client", - "url": "192.168.88.106:5433", - "dbname": "vectordb", - "user": "admin", - "password": "admin@Sommpanion_0.0" - }, - "fileserver": { - "description": "temporary file server", - "url": "https://fileserver.yiem.cc" - } - } -} \ No newline at end of file diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index ccfee40..ca882bc 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -12,9 +12,6 @@ module YiemAgent include("toolRegistry.jl") using .toolRegistry - # include("llmfunction.jl") - # using .llmfunction - include("agentCore.jl") using .agentCore @@ -30,4 +27,4 @@ module YiemAgent -end # module YiemAgent_v1 +end # end module diff --git a/src/agentCore.jl b/src/agentCore.jl index 45a4738..e8d6576 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -176,7 +176,7 @@ function yiemAgent( afterToolCall::Function=afterToolCall, # prepareNextTurn::Union{Function, Nothing}=nothing, # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, - sessionId::Union{String, Nothing}=nothing, + sessionId::Union{String, Nothing}="", maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, eventSink=eventSink, @@ -189,7 +189,7 @@ function yiemAgent( tools = OrderedDict{String, agentTool}() - registerAllTools(tools, mcpServer; eventSink=eventSink) + registerAllTools(tools, mcpServer; eventSink=eventSink, sessionId=sessionId) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 80f6662..5b870ec 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -38,7 +38,7 @@ Wrap an MCP tool definition as an `agentTool`. The returned tool's `execute` function calls the MCP server's "tools/call" method with the validated arguments. """ -function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing)::agentTool +function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing, sessionId=nothing)::agentTool name = tool_def["name"] title = get(tool_def, "title", get(tool_def, "label", name)) desc = get(tool_def, "description", "") @@ -68,7 +68,7 @@ function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSin signal::Union{Nothing,abortSignal}, eventSink) -> begin try - response = mcpserver("tools/call", name, args) + response = mcpserver("tools/call", name, args, sessionId) # Parse JSON-RPC 2.0 response envelope if haskey(response, "error") @@ -115,76 +115,74 @@ Handles pagination via `nextCursor`. Skips tools already registered. # Returns - `Int`: number of new tools registered """ -function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing)::Int - if mcpserver === nothing +function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::Int + if mcpserver === nothing + return 0 + end + new_count = 0 + try + eventSink("register_mcp_tools 1") + response = mcpserver("tools/list", sessionId) + eventSink("register_mcp_tools 2") + # Parse JSON-RPC 2.0 response envelope + if haskey(response, "error") + rpc_error = response["error"] + err_msg = get(rpc_error, "message", "Unknown MCP error") + println("[toolRegistry] MCP tools/list failed: $err_msg") return 0 end + if haskey(response, "result") + response = response["result"] + end - new_count = 0 + tools_array = response["tools"] + cursor = get(response, "nextCursor", nothing) - try - eventSink("register_mcp_tools 1") - response = mcpserver("tools/list") - eventSink("register_mcp_tools 2") - # Parse JSON-RPC 2.0 response envelope - if haskey(response, "error") - rpc_error = response["error"] - err_msg = get(rpc_error, "message", "Unknown MCP error") - println("[toolRegistry] MCP tools/list failed: $err_msg") - return 0 + for tool_def in tools_array + name = tool_def["name"] + if haskey(tools, name) + continue end + + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) + tools[name] = wrapped + new_count += 1 + end + + # Paginate: fetch remaining tools if nextCursor is present + while cursor !== nothing && cursor !== "" + response = sessionId !== nothing ? mcpserver("tools/list", sessionId) : mcpserver("tools/list") if haskey(response, "result") response = response["result"] end - - tools_array = response["tools"] + tools_array = get(response, "tools", Any[]) cursor = get(response, "nextCursor", nothing) - for tool_def in tools_array name = tool_def["name"] if haskey(tools, name) continue end - - wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink) + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) tools[name] = wrapped new_count += 1 end - - # Paginate: fetch remaining tools if nextCursor is present - while cursor !== nothing && cursor !== "" - response = mcpserver("tools/list") - if haskey(response, "result") - response = response["result"] - end - tools_array = get(response, "tools", Any[]) - cursor = get(response, "nextCursor", nothing) - for tool_def in tools_array - name = tool_def["name"] - if haskey(tools, name) - continue - end - wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink) - tools[name] = wrapped - new_count += 1 - end - end - - println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(tools))") - catch e - bt = catch_backtrace() - err_msg = sprint() do io - showerror(io, e, bt) - println(io) - end - - eventSink(err_msg) - - errMsg = sprint(showerror, e) - println("[toolRegistry] MCP tools/list failed: $errMsg") end - return new_count + println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(tools))") + catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + eventSink(err_msg) + + errMsg = sprint(showerror, e) + println("[toolRegistry] MCP tools/list failed: $errMsg") + end + + return new_count end """ @@ -234,7 +232,7 @@ pagination via nextCursor), then returns the full tool list. Subsequent calls: returns the current list (tools remain registered). """ -function listTool(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing)::agentTool +function listTool(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::agentTool return agentTool( name="listTools", label="List Tools", @@ -248,7 +246,7 @@ function listTool(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=no signal::Union{Nothing,abortSignal}, eventSink) -> begin # Discover and register MCP tools (idempotent — skips already registered) - new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink) + new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink, sessionId=sessionId) eventSink("tools dump: " * sprint(show, tools)) # Always include listTools itself in the count @@ -285,8 +283,8 @@ Register all tools for an agent: - `tools`: The tool dict to populate - `mcpserver`: A callable struct that communicates with the MCP server """ -function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing) - list_t = listTool(tools, mcpserver; eventSink=eventSink) +function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing, sessionId=nothing) + list_t = listTool(tools, mcpserver; eventSink=eventSink, sessionId=sessionId) registerTool(tools, list_t; eventSink=eventSink) end -- 2.52.0 From 7789748f25d3f05bb036f175cb3ac8a5ae68d973 Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 24 Aug 2026 15:16:28 +0700 Subject: [PATCH 101/112] update --- src/agentCore.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index e8d6576..b6f33d4 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -320,7 +320,7 @@ function _agentLoop(agent::yiemAgent) result = nothing # reset end end - yield() + sleep(0.1) end end -- 2.52.0 From 096baad09db4f177c778b831204925d47d8f0751 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 1 Sep 2026 10:36:27 +0700 Subject: [PATCH 102/112] update --- Manifest.toml | 224 +++--- Project.toml | 4 +- README.md | 424 ++++++++---- README_tools.md | 1599 ------------------------------------------- my_AI_prompt.md | 1 + src/agentCore.jl | 79 +-- src/api.jl | 10 + src/toolRegistry.jl | 124 ++-- src/utils.jl | 113 ++- 9 files changed, 608 insertions(+), 1970 deletions(-) delete mode 100644 README_tools.md create mode 100644 my_AI_prompt.md diff --git a/Manifest.toml b/Manifest.toml index dbec9cc..5ca0ac4 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -1,8 +1,20 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.12.6" +julia_version = "1.12.7" manifest_format = "2.0" -project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752" +project_hash = "eeddbbbbefd50bd46b098a2d54b2795d85daa76e" + +[[deps.AWS]] +deps = ["Base64", "Compat", "Dates", "Downloads", "GitHub", "HTTP", "IniFile", "JSON", "MD5", "Mocking", "OrderedCollections", "Random", "SHA", "ScopedValues", "Sockets", "URIs", "UUIDs", "XMLDict"] +git-tree-sha1 = "842075b12a7f763c5f815a04037a99b6ac346df9" +uuid = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc" +version = "1.99.0" + +[[deps.AWSS3]] +deps = ["AWS", "ArrowTypes", "Base64", "Compat", "Dates", "EzXML", "FilePathsBase", "HTTP", "MbedTLS", "Mocking", "OrderedCollections", "Retry", "SymDict", "URIs", "UUIDs", "XMLDict"] +git-tree-sha1 = "af99cea71364c8c4483405a6b03697d197ab4d3b" +uuid = "1c724243-ef5b-51ab-93f4-b0a88ac62a95" +version = "0.11.6" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -86,9 +98,9 @@ version = "1.11.0" [[deps.CSV]] deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] -git-tree-sha1 = "8d8e0b0f350b8e1c91420b5e64e5de774c2f0f4d" +git-tree-sha1 = "abed1e735dd4152f48c90cf0767e1790e25f332f" uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -version = "0.10.16" +version = "0.10.17" [[deps.CodeTracking]] deps = ["InteractiveUtils", "REPL", "UUIDs"] @@ -110,9 +122,9 @@ version = "0.4.6" [[deps.CodecZlib]] deps = ["TranscodingStreams", "Zlib_jll"] -git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" +git-tree-sha1 = "970758a3d591a2a5c2a907c53f2e2f8c1b1d3537" uuid = "944b1d66-785c-5afd-91f1-9de20f533193" -version = "0.7.8" +version = "0.7.9" [[deps.CodecZstd]] deps = ["TranscodingStreams", "Zstd_jll"] @@ -121,9 +133,10 @@ uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" version = "0.8.7" [[deps.CommonSolve]] -git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" +deps = ["PrecompileTools"] +git-tree-sha1 = "6c389fa857f6ca5a95474b52a52023fd77f24cb7" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" -version = "0.2.13" +version = "0.2.14" [[deps.Compat]] deps = ["TOML", "UUIDs"] @@ -143,7 +156,7 @@ version = "0.1.1" [[deps.CompilerSupportLibraries_jll]] deps = ["Artifacts", "Libdl"] uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" -version = "1.3.0+1" +version = "1.3.1+2" [[deps.CompositionsBase]] git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad" @@ -181,9 +194,9 @@ uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" version = "4.2.0" [[deps.DBInterface]] -git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b" +git-tree-sha1 = "265d7f87f329c063cbde41920f9d00713a43cedd" uuid = "a10d1c49-ce27-4219-8d33-6db1a4562965" -version = "2.6.1" +version = "2.7.0" [[deps.DataAPI]] git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" @@ -238,9 +251,9 @@ version = "1.11.0" [[deps.Distributions]] deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] -git-tree-sha1 = "d2facc77c08c1c2bfb1a77c148edd05b3db5410b" +git-tree-sha1 = "a958ab3a40c755563f5e1405c0846cb0446bf19d" uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" -version = "0.25.130" +version = "0.25.131" [deps.Distributions.extensions] DistributionsChainRulesCoreExt = "ChainRulesCore" @@ -274,15 +287,11 @@ git-tree-sha1 = "d2e49e7efd29719d6f28b891b0e0e159daa9d2b4" uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" version = "0.1.11" -[[deps.FileIO]] -deps = ["Pkg", "Requires", "UUIDs"] -git-tree-sha1 = "6621fef488e496356c9c9625d0562c12a6070819" -uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" -version = "1.20.0" -weakdeps = ["HTTP"] - - [deps.FileIO.extensions] - HTTPExt = "HTTP" +[[deps.EzXML]] +deps = ["Printf", "XML2_jll"] +git-tree-sha1 = "7ea1aa5869e2626ccae84480e4f37185bc6f41d3" +uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615" +version = "1.2.3" [[deps.FilePathsBase]] deps = ["Compat", "Dates"] @@ -318,17 +327,24 @@ uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" version = "1.11.0" [[deps.Gamma]] -git-tree-sha1 = "86f86b6168a016ed88e4ae4e64577b98c3b59e8e" +deps = ["LogExpFunctions"] +git-tree-sha1 = "becc397f7cfb06e343496ae6ffb04818a851da51" uuid = "a0844989-3bd2-4988-8bea-c9407ab0941b" -version = "1.1.0" +version = "1.2.0" [[deps.GeneralUtils]] -deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"] -git-tree-sha1 = "129b8fa1bf3bf6d8c0a080f39db09b9b986ef8de" +deps = ["AWS", "AWSS3", "CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "OrderedCollections", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "URIs", "UUIDs"] +git-tree-sha1 = "deb2a19735d937b6265d18f55ce0788ac574e9c7" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.11" +version = "0.6.6" + +[[deps.GitHub]] +deps = ["Base64", "Dates", "HTTP", "JSON", "MbedTLS", "Sockets", "SodiumSeal", "URIs"] +git-tree-sha1 = "33de1bc5665494f0a9669fd6027e28180709d874" +uuid = "bc5e4493-9b4d-5f90-b8aa-2b2bcaad7a26" +version = "5.13.0" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] @@ -345,9 +361,9 @@ version = "1.14.0" [[deps.HTTP]] deps = ["Base64", "CodecZlib", "Dates", "EnumX", "PrecompileTools", "Random", "Reseau", "SHA", "URIs", "UUIDs", "Zlib_jll"] -git-tree-sha1 = "0a58fbbdee93d132a2fb1159b7f5e1b5c2465e71" +git-tree-sha1 = "49a90cd540681d5b79bf5c8ca13c92677e37005e" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "2.6.4" +version = "2.6.6" [[deps.HashArrayMappedTries]] git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" @@ -377,6 +393,11 @@ git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d" uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" version = "0.1.5" +[[deps.IniFile]] +git-tree-sha1 = "f550e6e32074c939295eb5ea6de31849ac2c9625" +uuid = "83e8ac13-25f8-5344-8a64-a9f2b223428f" +version = "0.5.1" + [[deps.InlineStrings]] git-tree-sha1 = "8f3d257792a522b4601c24a577954b0a8cd7334d" uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" @@ -436,9 +457,9 @@ version = "1.8.0" [[deps.JSON]] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] -git-tree-sha1 = "65979512c25a0727f050e6e4be40f0fd9ec893f7" +git-tree-sha1 = "c7345ab1a7ca4dc8a02c9f6510da0d9857bbe513" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "1.7.0" +version = "1.7.1" weakdeps = ["ArrowTypes"] [deps.JSON.extensions] @@ -471,18 +492,10 @@ git-tree-sha1 = "0f2899fdadaab4b8f57db558ba21bdb4fb52f1f0" uuid = "b39eb1a6-c29a-53d7-8c32-632cd16f18da" version = "1.21.3+0" -[[deps.LLMMCTS]] -deps = ["GeneralUtils", "JSON", "PrettyPrinting"] -git-tree-sha1 = "3dff98131dfa79be8c9bd84fc51cb0ba1832c472" -repo-rev = "main" -repo-url = "https://git.yiem.cc/ton/LLMMCTS" -uuid = "d76c5a4d-449e-4835-8cc4-dd86ec44f241" -version = "0.1.5" - [[deps.LaTeXStrings]] -git-tree-sha1 = "dda21b8cbd6a6c40d9d02a73230f9d70fed6918c" +git-tree-sha1 = "f88f3ccef05a6a72a0cf0ed417c8fd68530f4ab2" uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" -version = "1.4.0" +version = "1.4.1" [[deps.LayerDicts]] git-tree-sha1 = "6087ad3521d6278ebe5c27ae55e7bbb15ca312cb" @@ -530,6 +543,12 @@ version = "1.11.3+1" uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" version = "1.11.0" +[[deps.Libiconv_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" +uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" +version = "1.18.0+0" + [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -567,6 +586,12 @@ git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33" uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" version = "1.10.1+0" +[[deps.MD5]] +deps = ["Random", "SHA"] +git-tree-sha1 = "1576f756617d31eb397a4a517b68562fd28dc2b4" +uuid = "6ac74813-4b46-53a4-afec-0b5dc9d7885c" +version = "0.2.3" + [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" @@ -655,7 +680,7 @@ version = "0.8.7+0" [[deps.OpenSSL_jll]] deps = ["Artifacts", "Libdl"] uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" -version = "3.5.4+0" +version = "3.5.6+0" [[deps.OpenSpecFun_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] @@ -684,15 +709,6 @@ git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" version = "2.8.7" -[[deps.Pkg]] -deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] -uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -version = "1.12.1" -weakdeps = ["REPL"] - - [deps.Pkg.extensions] - REPLExt = "REPL" - [[deps.PooledArrays]] deps = ["DataAPI", "Future"] git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" @@ -718,9 +734,9 @@ version = "0.4.3" [[deps.PrettyTables]] deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"] -git-tree-sha1 = "4ac881f5432bd93463a41767a814a45245be22b6" +git-tree-sha1 = "1b8aa19f229b1cea7fc93874a52e49db6a854450" uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -version = "3.4.6" +version = "3.4.8" [deps.PrettyTables.extensions] PrettyTablesExcelExt = "XLSX" @@ -781,15 +797,20 @@ version = "1.3.1" [[deps.Reseau]] deps = ["NetworkOptions", "OpenSSL_jll", "PrecompileTools", "Random", "SHA"] -git-tree-sha1 = "701ef63506992668c0069a4d1ac1540b77258e6c" +git-tree-sha1 = "aaee74fab545e9af4b21d10c2203c93c636c4ae0" uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36" -version = "1.3.6" +version = "1.4.1" + +[[deps.Retry]] +git-tree-sha1 = "41ac127cd281bb33e42aba46a9d3b25cd35fc6d5" +uuid = "20febd7b-183b-5ae2-ac4a-720e7ce64774" +version = "0.4.1" [[deps.Revise]] deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] -git-tree-sha1 = "ec46aed6a3a8cc6b67839ca361e7b4aa32eaeee1" +git-tree-sha1 = "ab0f5630e37968bc49b8c4cedd3279a7d30b9486" uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" -version = "3.16.3" +version = "3.17.0" weakdeps = ["Distributed"] [deps.Revise.extensions] @@ -809,9 +830,9 @@ version = "0.5.2+0" [[deps.Roots]] deps = ["Accessors", "CommonSolve", "Printf"] -git-tree-sha1 = "7fb25a964849d90a0446366cdefca822e0e84900" +git-tree-sha1 = "13a9e0164267bb9ebc55c1c5d72fceea8f09555b" uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" -version = "3.0.6" +version = "3.0.7" [deps.Roots.extensions] RootsChainRulesCoreExt = "ChainRulesCore" @@ -833,14 +854,6 @@ version = "3.0.6" uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" version = "0.7.0" -[[deps.SQLLLM]] -deps = ["CSV", "DataFrames", "DataStructures", "Dates", "FileIO", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "PrettyPrinting", "Random", "Revise", "StatsBase", "Tables", "URIs", "UUIDs"] -git-tree-sha1 = "bae2fd2e2b087753fbb3415896be41df1ae0eb90" -repo-rev = "main" -repo-url = "https://git.yiem.cc/ton/SQLLLM" -uuid = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" -version = "0.2.8" - [[deps.SQLStrings]] git-tree-sha1 = "55de0530689832b1d3d43491ee6b67bd54d3323c" uuid = "af517c2e-c243-48fa-aab8-efac3db270f5" @@ -884,6 +897,12 @@ git-tree-sha1 = "907703e0d50846f300650d7225bdcab145b7bca9" uuid = "4f5b5e99-b0ad-42cd-b47a-334e172ec8bd" version = "1.1.2" +[[deps.SodiumSeal]] +deps = ["Base64", "Libdl", "libsodium_jll"] +git-tree-sha1 = "80cef67d2953e33935b41c6ab0a178b9987b1c99" +uuid = "2133526b-2bfb-4018-ac12-889fb3908a75" +version = "0.1.1" + [[deps.SortingAlgorithms]] deps = ["DataStructures"] git-tree-sha1 = "13cd91cc9be159e3f4d95b857fa2aa383b53772a" @@ -897,9 +916,9 @@ version = "1.12.0" [[deps.SpecialFunctions]] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] -git-tree-sha1 = "c3ac026e735264e9bdc6a9bcbd1b1e781b36e3bc" +git-tree-sha1 = "429071b23f4c9a13fb6582f807cc2ef454082408" uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "2.8.3" +version = "2.9.0" [deps.SpecialFunctions.extensions] SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" @@ -909,9 +928,9 @@ version = "2.8.3" [[deps.StaticArrays]] deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] -git-tree-sha1 = "246a8bb2e6667f832eea063c3a56aef96429a3db" +git-tree-sha1 = "fac51faf3bb96e8bc0bf6f9f39ca4955652776bb" uuid = "90137ffa-7385-5640-81b9-e52037218182" -version = "1.9.18" +version = "1.9.19" [deps.StaticArrays.extensions] StaticArraysChainRulesCoreExt = "ChainRulesCore" @@ -928,9 +947,9 @@ version = "1.4.4" [[deps.Statistics]] deps = ["LinearAlgebra"] -git-tree-sha1 = "ae3bb1eb3bba077cd276bc5cfc337cc65c3075c0" +git-tree-sha1 = "389592b8592e5fb48f498ff60dc6acb4a0e62953" uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" -version = "1.11.1" +version = "1.11.4" weakdeps = ["SparseArrays"] [deps.Statistics.extensions] @@ -944,9 +963,9 @@ version = "1.8.0" [[deps.StatsBase]] deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] -git-tree-sha1 = "e4d7a1a0edc20af42689ea6f4f3587a2175d50ee" +git-tree-sha1 = "adb9da019510162e67a4493fc235c23203d8b09e" uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" -version = "0.34.12" +version = "0.34.13" [[deps.StatsFuns]] deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] @@ -964,15 +983,15 @@ version = "2.2.1" [[deps.StringDistances]] deps = ["Distances", "StatsAPI"] -git-tree-sha1 = "cd83a04baf746e3b43b83c61b7de77ab0409b80a" +git-tree-sha1 = "456f343c7a6c8f52d8b501c0da2a05538ab1b907" uuid = "88034a9c-02f8-509d-84a9-84ec65e18404" -version = "1.0.0" +version = "1.0.1" [[deps.StringManipulation]] deps = ["PrecompileTools"] -git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484" +git-tree-sha1 = "773065c6e0e903924a9d838259be74338422aef2" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" -version = "0.4.7" +version = "0.5.0" [[deps.StringViews]] git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8" @@ -987,9 +1006,9 @@ version = "1.11.0" [[deps.StructUtils]] deps = ["Dates", "UUIDs"] -git-tree-sha1 = "c65ae4aa47e543c278aea0a3468786d33021a3ff" +git-tree-sha1 = "2d0fc55c61321ba245c47be599570d11bac50303" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" -version = "2.8.4" +version = "2.8.5" [deps.StructUtils.extensions] StructUtilsMeasurementsExt = ["Measurements"] @@ -1014,6 +1033,12 @@ deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" version = "7.8.3+2" +[[deps.SymDict]] +deps = ["Test"] +git-tree-sha1 = "0108ccdaea3ef69d9680eeafc8d5ad198b896ec8" +uuid = "2da68c74-98d7-5633-99d6-8493888d7b1e" +version = "0.3.0" + [[deps.TOML]] deps = ["Dates"] uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" @@ -1021,9 +1046,9 @@ version = "1.0.3" [[deps.TZJData]] deps = ["Artifacts"] -git-tree-sha1 = "72df96b3a595b7aab1e101eb07d2a435963a97e2" +git-tree-sha1 = "d3b30a9f29a898e21fb4bdf280101b7a12e1f39c" uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7" -version = "1.5.0+2025b" +version = "1.5.1+2025b" [[deps.TableTraits]] deps = ["IteratorInterfaceExtensions"] @@ -1033,14 +1058,9 @@ version = "1.0.1" [[deps.Tables]] deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"] -git-tree-sha1 = "0f38a06c83f0007bbab3cf911262841c9a0f07e0" +git-tree-sha1 = "a94d9bdda1b7bed0046cea645639ab3f62196fac" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" -version = "1.13.0" - -[[deps.Tar]] -deps = ["ArgTools", "SHA"] -uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" -version = "1.10.0" +version = "1.14.0" [[deps.Test]] deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] @@ -1063,9 +1083,9 @@ uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" version = "0.11.3" [[deps.URIs]] -git-tree-sha1 = "3b0738bd7c5645641845da25cbd99800b8718689" +git-tree-sha1 = "908fec9df6c5de98548ead82a468c95ccf6cd263" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" -version = "1.6.2" +version = "1.7.0" [[deps.UTCDateTimes]] deps = ["Dates", "TimeZones"] @@ -1093,8 +1113,20 @@ git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" version = "1.6.1" +[[deps.XML2_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Libiconv_jll", "Zlib_jll"] +git-tree-sha1 = "3f3315d89fc954a28f5b471bce698ed6e27481be" +uuid = "02c8fc9c-b97f-50b9-bbe4-9be30ff0a78a" +version = "2.15.3+0" + +[[deps.XMLDict]] +deps = ["EzXML", "IterTools", "OrderedCollections"] +git-tree-sha1 = "8e8cdc9c93e1f442eba471b56db6f86ffeb1a33a" +uuid = "228000da-037f-5747-90a9-8195ccbf91a5" +version = "0.4.2" + [[deps.YiemAgent]] -deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serialization", "URIs", "UUIDs"] +deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "Serialization", "URIs", "UUIDs"] path = "." uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" version = "0.8.0" @@ -1122,12 +1154,12 @@ uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" version = "1.0.21+0" [[deps.msghandler]] -deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"] -git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f" +deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "UUIDs"] +git-tree-sha1 = "e7889a120327db84cdf1b1acf4a2c7caba6e8ef7" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/msghandler" uuid = "f2724d33-f338-4a57-b9f8-1be882570d10" -version = "1.2.1" +version = "1.4.0" [[deps.nghttp2_jll]] deps = ["Artifacts", "Libdl"] diff --git a/Project.toml b/Project.toml index 31ce449..62dde12 100644 --- a/Project.toml +++ b/Project.toml @@ -26,8 +26,8 @@ msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10" Base64 = "1.11.0" CSV = "0.10.15" DataFrames = "1.7.0" -GeneralUtils = "0.5.10" +GeneralUtils = "0.6.6" HTTP = "2.4.0" JSON = "1.6.1" NATS = "0.1.0" -msghandler = "1.2.1" +msghandler = "1.4.0" diff --git a/README.md b/README.md index ba83600..841f3ad 100644 --- a/README.md +++ b/README.md @@ -2,138 +2,310 @@ Julia framework for building agents with tool use and MCP (Model Context Protocol) support. -## Getting Started +## Quick Start -1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, NATS, DataFrames` -2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS -3. Create a `yiemAgent` with an LLM callable and MCP server: - ```julia - agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, eventSink=yourSink) - ``` -4. Call `promptAgent(agent, message)` then `takeResponse(agent)` +```julia +using YiemAgent + +# 1. Create an LLM callable (see "Custom LLM Callable" below) +llmCall = ... + +# 2. Create a callable MCP server (see "MCP Server Integration" below) +mcpServer = ... + +# 3. Create the agent +agent = yiemAgent(llmCall; mcpServer=mcpServer, eventSink=println) + +# 4. Interact +promptAgent(agent, "What tools do you have?") +response = takeResponse(agent) + +# 5. Stop when done +stopAgent(agent) +``` + +## Agent API + +| Function | Description | +|----------|-------------| +| `promptAgent(agent, text)` | Send plain text to the agent's input channel | +| `promptAgent(agent, msg)` | Send a structured message (OpenAI format dict) to the input channel | +| `takeResponse(agent)` | Block and take the agent's response from output channel | +| `followUp(agent, text)` | Send plain text follow-up while agent is still processing | +| `followUp(agent, msg)` | Send a structured follow-up (OpenAI format dict) while agent is processing | +| `stopAgent(agent)` | Gracefully stop the agent and close channels | + +## Creating a yiemAgent + +```julia +yiemAgent(llmCall; + systemPrompt::String = "You are a helpful assistant.", + model = nothing, + messages::Vector{agentMessage} = agentMessage[], + prepareContext::Function = prepareContext, + formatMsgForLLM::Function = formatMsgForLLM, + beforeToolCall::Function = beforeToolCall, + afterToolCall::Function = afterToolCall, + sessionId::Union{String, Nothing} = nothing, + maxRetryDelayMs::Union{Int64, Nothing} = nothing, + parallelToolExecute::Bool = false, + eventSink::Function = eventSink, + mcpServer = nothing +) +``` + +| Parameter | Description | +|-----------|-------------| +| `llmCall` | **(required)** Function that receives formatted messages and returns an LLM response | +| `systemPrompt` | System prompt for the agent | +| `model` | LLM model configuration (`llmModel` struct) | +| `messages` | Initial conversation messages | +| `prepareContext` | Hook to transform messages/context before each LLM call | +| `formatMsgForLLM` | Hook to convert agent context to LLM API format | +| `beforeToolCall` | Hook to block/allow tool execution | +| `afterToolCall` | Hook to post-process tool results | +| `sessionId` | Optional session identifier for MCP server | +| `maxRetryDelayMs` | Maximum retry delay in milliseconds | +| `parallelToolExecute` | Run multiple tool calls in parallel | +| `eventSink` | Callback for agent events/debug messages | +| `mcpServer` | Callable struct for MCP server communication | + +## Usage Examples + +### Text Messages + +```julia +# Send plain text +promptAgent(agent, "What tools do you have?") +response = takeResponse(agent) + +# Follow-up while agent is processing +promptAgent(agent, "Tell me about wine") +followUp(agent, "Also include prices") +response = takeResponse(agent) +``` + +### Structured Messages (OpenAI Format) + +```julia +promptAgent(agent, Dict("role" => "user", "content" => "Hello")) +response = takeResponse(agent) +``` + +### Output Format + +`takeResponse()` returns a `Dict{String, Any}` in OpenAI message format: + +```julia +Dict( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => "The weather in Tokyo is Sunny, 22°C."), + # ... more content blocks + ] +) +``` + +Content block types: + +| Block Type | Fields | Description | +|------------|--------|-------------| +| `text` | `"type" => "text"`, `"text" => "..."` | Plain text response from the LLM | +| `image_url` | `"type" => "image_url"`, `"image_url" => Dict("url" => "...")` | Image content (rare, used by multimodal models) | + +Example — extracting the text response: + +```julia +response = takeResponse(agent) +text_response = first(c["text"] for c in response["content"] if c["type"] == "text") +``` + +### Multimodal Messages (Text + Image) + +```julia +# Encode images to data URIs +image_path = "test/image.png" +image_bytes = read(image_path) +base64_string = base64encode(image_bytes) +data_uri = "data:image/png;base64,$(base64_string)" + +# Build message with text and image content blocks +message = Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "Do you have Brunello di Montalcino?"), + Dict("type" => "image_url", "image_url" => Dict("url" => data_uri)) + ] +) +promptAgent(agent, message) +response = takeResponse(agent) +``` + +### Custom Hooks + +```julia +# beforeToolCall — request user approval for destructive tools +function my_before_tool_call(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + if context.toolCall.name == "deleteFile" + return beforeToolCallResult(true, "User must approve file deletion") + end + return beforeToolCallResult(false, "N/A") +end + +# afterToolCall — mask sensitive data from results +function my_after_tool_call(context::afterToolCallContext, signal::abortSignal)::Union{agentToolResult, Nothing} + if context.isError + return agentToolResult( + [textContent("The tool encountered an error. Please try again.")], + context.result.details, context.result.usage, context.result.terminate + ) + end + return nothing +end + +agent = yiemAgent(llmCall; + mcpServer=mcpServer, + beforeToolCall=my_before_tool_call, + afterToolCall=my_after_tool_call, + eventSink=println +) +``` + +### Custom LLM Callable + +```julia +function my_llm_call(messages_dict::Dict{String, Any}) + # messages_dict contains "messages" key with OpenAI-format message array, + # plus optional "tools", "temperature", etc. + + # Call your LLM provider (OpenAI, Anthropic, local model, etc.) + raw_response = call_my_llm_provider(messages_dict) + + # Return a dict with these fields: + return Dict( + "message" => Dict("role" => "assistant", "content" => raw_response["content"]), + "finish_reason" => raw_response["finish_reason"], + "api" => "my_provider", + "provider" => "my_provider", + "model" => "my-model-name", + "usage" => llmUsage(100, 50) + ) +end +``` + +### Custom Context Preparation + +```julia +function my_prepare_context(state::agentState, eventSink)::agentContext + sysPrompt = state.systemPrompt * "\nCurrent time: $(now())" + msgs = deepcopy(state.messages) + + # Prune old messages to fit context window + max_messages = 20 + if length(msgs) > max_messages + msgs = msgs[end-max_messages+1:end] + end + + return agentContext(sysPrompt, msgs, state.tools) +end + +agent = yiemAgent(llmCall; prepareContext=my_prepare_context, ...) +``` + +## MCP Server Integration + +YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement `tools/list` and `tools/call` methods. + +### Protocol + +```julia +# tools/list — returns tool definitions +mcpServer("tools/list") +# → Dict("jsonrpc"=>"2.0", "id"=>1, "result"=>Dict("tools"=>[...], "nextCursor"=>...)) + +# tools/call — executes a tool +mcpServer("tools/call", toolName, arguments) +# → Dict("jsonrpc"=>"2.0", "id"=>2, "result"=>Dict("content"=>[...], "isError"=>...)) + +# Protocol error responses include "error" instead of "result": +Dict("jsonrpc"=>"2.0", "id"=>2, "error"=>Dict("code"=>-32602, "message"=>"...")) +``` + +### Example: NATS-based MCP Server Client + +```julia +struct mcpServer + natsConn::NATS.Connection + topic::String + senderID::String + fileserver_url::String +end + +# tools/list implementation +function (c::mcpServer)(method::String) + payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}()) + payloads = [("payload", payload, "dictionary")] + _, msg_envelope = msghandler.smartpack(c.topic, payloads; + sender_id=c.senderID, msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url) + reply = NATS.request(c.natsConn, c.topic, msg_envelope, timeout=180) + incoming = msghandler.smartunpack(String(reply.payload)) + return incoming["payloads"][1][2] +end + +# tools/call implementation +function (c::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}) + payload = Dict("jsonrpc" => "2.0", "id" => 2, "method" => method, + "params" => Dict("name" => toolName, "arguments" => arguments)) + payloads = [("payload", payload, "dictionary")] + _, msg_envelope = msghandler.smartpack(c.topic, payloads; + sender_id=c.senderID, msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url) + reply = NATS.request(c.natsConn, c.topic, msg_envelope, timeout=180) + incoming = msghandler.smartunpack(String(reply.payload)) + return incoming["payloads"][1][2] +end +``` + +## Tool Discovery + +Tools are discovered dynamically via the MCP server: + +1. `listTools` is the only pre-registered tool at agent creation +2. When the LLM calls `listTools()`, tools are discovered from the MCP server and registered at runtime +3. Supports pagination via `nextCursor` for large tool sets +4. New tools become immediately available without restarting the agent + +## Hooks + +Hooks allow customization of the agent's behavior at key points in the execution pipeline. + +| Hook | Signature | Purpose | +|------|-----------|---------| +| `prepareContext` | `(state::agentState, eventSink) -> agentContext` | Transform messages/context before LLM call (filter tools, inject context, prune messages) | +| `formatMsgForLLM` | `(ctx::agentContext, eventSink) -> Dict` | Convert agent context to LLM API message format | +| `beforeToolCall` | `(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult` | Block/allow tool execution; request user approval | +| `afterToolCall` | `(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}` | Post-process tool results; mask sensitive data; set `terminate` flag | +| `eventSink` | `(msg) -> nothing` | Callback for agent events/debug messages throughout the pipeline | + +## Agent Lifecycle + +1. **Creation** — `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel` +2. **Input** — User messages enter via `promptAgent()` (immediate) or `followUp()` (queued while agent is busy) +3. **Processing** — `_processMessage()` handles LLM calls, tool execution, and conversation history in a loop +4. **Tool Execution Pipeline** — each tool call follows three phases: + - `prepareToolCall` — resolve tool, validate arguments, run `beforeToolCall` hook + - `executePreparedToolCall` — invoke `tool.execute()` + - `finalizeExecutedToolCall` — run `afterToolCall` hook, construct `toolResultMessage` +5. **Termination** — tool `execute` functions can set `terminate=true` to stop the agent loop +6. **Shutdown** — `stopAgent()` sends a `:shutdown` signal, drains channels, and closes the agent ## Architecture ``` src/ ├── YiemAgent.jl # Module entry point -├── type.jl # Core types (messages, tools, agent state) +├── type.jl # Core types (messages, tools, agent state, events) ├── utils.jl # Message formatting, context preparation, validation -├── agentCore.jl # Agent loop, tool execution pipeline -├── api.jl # Public API (promptAgent, takeResponse, followUp, stopAgent) -└── toolRegistry.jl # Tool store, MCP discovery, listTools registration +├── toolRegistry.jl # Tool store, MCP discovery, listTools registration +├── agentCore.jl # Agent loop, tool execution pipeline, yiemAgent struct +└── api.jl # Public API (promptAgent, takeResponse, followUp, stopAgent) ``` - -## MCP Protocol - -YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement: - -```julia -# tools/list — returns tool definitions -mcpServer("tools/list") # → Dict("jsonrpc"=>"2.0", "id"=>1, "result"=>Dict("tools"=>[...], "nextCursor"=>...)) - -# tools/call — executes a tool -mcpServer("tools/call", toolName, arguments) # → Dict("jsonrpc"=>"2.0", "id"=>2, "result"=>Dict("content"=>[...], "isError"=>...)) -``` - -Protocol error responses include `"error"` instead of `"result"`: -```julia -Dict("jsonrpc"=>"2.0", "id"=>2, "error"=>Dict("code"=>-32602, "message"=>"...")) -``` - -### Implementing the MCP Server Client - -You must provide a callable struct that communicates with your MCP server. Example using NATS: - -```julia -struct mcpServer - natsConn::NATS.Connection - topic::String - senderID::String - fileserver_url::String -end - -# tools/list implementation -function (c::mcpServer)(method::String) - if method != "tools/list" - error("mcpServer: unexpected method '$method' (expected 'tools/list')") - end - - payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}()) - payloads = [("payload", payload, "dictionary")] - _, msg_envelope_json_str = msghandler.smartpack( - c.topic, payloads; - sender_id=c.senderID, - msg_purpose="mcp_tools_list", - fileserver_url=c.fileserver_url) - - reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) - incoming_env = msghandler.smartunpack(String(reply.payload)) - return incoming_env["payloads"][1][2] -end - -# tools/call implementation -function (c::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}) - if method != "tools/call" - error("mcpServer: unexpected method '$method' (expected 'tools/call')") - end - - payload = Dict( - "jsonrpc" => "2.0", - "id" => 2, - "method" => method, - "params" => Dict( - "name" => toolName, - "arguments" => arguments - ) - ) - payloads = [("payload", payload, "dictionary")] - _, msg_envelope_json_str = msghandler.smartpack( - c.topic, payloads; - sender_id=c.senderID, - msg_purpose="mcp_tool_call", - fileserver_url=c.fileserver_url) - - reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) - incoming_env = msghandler.smartunpack(String(reply.payload)) - return incoming_env["payloads"][1][2] -end -``` - -See `test/runtest.jl` for the complete working example. - -## Tool Discovery - -Tools are discovered dynamically via MCP server: - -1. `listTool` is the only pre-registered tool -2. When the LLM calls `listTools()`, tools are discovered from the MCP server and registered at runtime -3. Supports pagination via `nextCursor` for large tool sets - -## Agent API - -| Function | Description | -|----------|-------------| -| `promptAgent(agent, msg)` | Send a message to the agent's input channel | -| `takeResponse(agent)` | Block and take the agent's response from output channel | -| `followUp(agent, msg)` | Send a follow-up message while agent is still processing | -| `stopAgent(agent)` | Gracefully stop the agent and close channels | - -## Agent Lifecycle - -1. `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel` -2. User messages enter via `promptAgent()` or `followUp()` -3. `_processMessage()` handles LLM calls, tool execution, and conversation history -4. Tool execution follows three phases: `prepareToolCall` → `executePreparedToolCall` → `finalizeExecutedToolCall` -5. `beforeToolCall`/`afterToolCall` hooks allow pre/post-processing of tool calls -6. Tool `execute` functions can set `terminate=true` to stop the agent loop - -## Hooks - -| Hook | Signature | Purpose | -|------|-----------|---------| -| `prepareContext` | `(state, sink, llmCall) -> ctx` | Transform messages/context before LLM call | -| `formatMsgForLLM` | `(ctx, sink) -> dict` | Convert agent context to LLM API format | -| `beforeToolCall` | `(context, signal) -> result` | Block/allow tool execution | -| `afterToolCall` | `(context, signal) -> result` | Post-process tool results | -| `eventSink` | `(msg) -> nothing` | Callback for agent events/debug messages | diff --git a/README_tools.md b/README_tools.md deleted file mode 100644 index fb492f1..0000000 --- a/README_tools.md +++ /dev/null @@ -1,1599 +0,0 @@ -# Tools — Complete End-to-End Lifecycle - -This document describes the complete tool lifecycle in the YiemAgent framework, from definition through execution, for framework authors and maintainers who need a thorough understanding of the architecture. - ---- - -## Table of Contents - -1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) -2. [Overview](#2-overview) -3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) -4. [Tool Registration — Static Registration](#4-tool-registration--static-registration) -5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) -6. [Message Processing Pipeline](#6-message-processing-pipeline) -7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) -8. [The Per-Call Pipeline — Prepare, Execute, Finalize](#8-the-per-call-pipeline--prepare-execute-finalize) -9. [Execution Modes — Sequential vs Parallel](#9-execution-modes--sequential-vs-parallel) -10. [Tool Call Batches & Termination Logic](#10-tool-call-batches--termination-logic) -11. [Tool Result Message Creation](#11-tool-result-message-creation) -12. [Error Handling & Recovery Pattern](#12-error-handling--recovery-pattern) -13. [Event System — Tool Lifecycle Events](#13-event-system--tool-lifecycle-events) -14. [Agent Lifecycle Hooks](#14-agent-lifecycle-hooks) -15. [Self-Modifying Tools](#15-self-modifying-tools) -16. [Complete End-to-End Example](#16-complete-end-to-end-example) -17. [Tool File Contract](#17-tool-file-contract) -18. [Adding New Tools](#18-adding-new-tools) -19. [Appendix: Type Reference](#19-appendix-type-reference) - ---- - -## 1. Quick Start: Tool Lifecycle - -This section shows the complete lifecycle from tool registration through execution and result extraction. Each step maps to the detailed sections below. - -### Step 1: Discover — `listTools` - -The agent calls the `listTools` tool to see available tools and detect name collisions before creating new ones. - -```julia -# The listTools tool is auto-injected via listTool(store) — no manual registration needed -tool = listTool(store) # Returns an agentTool that, when executed, lists all tools in the store -``` - -**Result extraction:** -```julia -result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) -# result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..." -``` - -**Source:** `toolRegistry.jl:43-98` - ---- - -### Step 2: Register — `register_all_tools()` - -Tools are statically defined in `src/tools/` and registered at module initialization via `register_all_tools()`. Each tool function (e.g., `getWeatherTool()`, `getTimeTool()`, `writeToolTool()`) is called to create the `agentTool` struct. `listTool` is auto-registered so the LLM can discover available tools. - -```julia -using YiemAgent, YiemAgent.toolRegistry - -store = toolStore(name="myAgent") -tools = register_all_tools(store) -# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs -# Also auto-registers listTools for runtime discovery -``` - -**Result extraction:** -```julia -all_tools = getTools(store) # OrderedDict{String, agentTool} -# Keys: "getWeather", "getTime", "writeTool", "listTools" -getWeather_tool = all_tools["getWeather"] - -# Manual registration (alternative to register_all_tools) -registerTool(store, my_tool) -clearTools(store) # Clear all tools from store -``` - -**Source:** `toolRegistry.jl:38-40, 127-143` - ---- - -### Step 2.5: Create Agent with Tools - -Wire the registered tools into a new `yiemAgent` instance. The `yiemAgent` constructor calls `register_all_tools()` automatically. - -```julia -using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry - -# Create agent — tools are registered automatically via register_all_tools() -agent = yiemAgent( - llmCall = my_llm_call, # Function that calls the LLM API - eventSink = my_event_sink, # Function for TUI/logging -) -``` - -**Key constructor parameters:** - -| Parameter | Type | Required | Purpose | -|-----------|------|----------|---------| -| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | -| `eventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | -| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | -| `model` | `llmModel` | No | LLM model config | -| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | - -Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. - -**Source:** `type.jl:609-657`, `toolRegistry.jl:127-143` - ---- - -### Step 3: Use — Tool Execution - -Tools can be used in two ways: - -**Direct execution (testing / standalone):** -```julia -using YiemAgent.type - -sig = nothing -op = x -> x # no-op partial result callback - -# Execute a loaded tool directly -result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) -``` - -**Via agent loop (production):** -``` -user message → promptAgent(agent, Dict("role"=>"user", "content"=>...)) - → _agentLoop detects message → @spawn _processMessage(agent) - → prepareContext → formatMsgForLLM → llmCall - → LLM returns tool_calls - → executeToolCalls(context, response, tool_call_list, config, signal, emit) - → prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall - → createToolResultMessage → batch.messages (toolResultMessage[]) -``` - -**Source:** Direct: `test/toolTest.jl:81-99` | Agent: `agentCore.jl:35-311` - ---- - -### Step 4: Extract Result - -**`agentToolResult`** (raw tool output, `type.jl:429-434`): -```julia -result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) - -result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C") -result.content[1].text # "Weather in Tokyo: Sunny, 22°C" -result.details # Dict{Any,Any}() — tool-specific metadata -result.usage # nothing — llmUsage tracking (optional) -result.terminate # false — signals loop termination -``` - -**`toolResultMessage`** (wrapped for conversation history, `type.jl:152-191`): -```julia -msg = batch.messages[1] # toolResultMessage - -msg.toolCallId # "call-1" -msg.toolName # "getWeather" -msg.content # Vector{messageContent} -msg.isError # false -msg.details # tool-specific metadata -msg.timestamp # DateTime -``` - ---- - -## 2. Overview - -The tool system follows a **three-phase pipeline** per tool call: - -``` -PREPARE → EXECUTE → FINALIZE -``` - -Each phase has a single responsibility and produces an intermediate result: - -| Phase | Function | Input | Output | Purpose | -|-------|----------|-------|--------|---------| -| Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | -| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `eventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results | -| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | - -The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. - ---- - -## 3. Tool Definition — The `agentTool` Struct - -**Source:** `type.jl:261-281` - -Every tool is an `agentTool` struct with the following fields: - -```julia -struct agentTool - name::String # Unique identifier (e.g. "getWeather") - label::String # Human-readable name (e.g. "Weather Lookup") - description::String # What the tool does (shown to the LLM for selection) - inputSchema::Any # JSON Schema (MCP format) describing parameters - execute::Function # Core execution: (toolCallId, args, signal, onPartialResult) -> agentToolResult - prepareArguments::Union{Function, Nothing} # Optional: (args) -> modified_args (before validation) - validateRequiredArgs::Union{Function, Nothing} # Optional: (args) -> Union{Nothing, String} error - parallelToolExecute::Bool # Override: run in parallel with other tools -end -``` - -### Field Details - -| Field | Required | Signature | Purpose | -|-------|----------|-----------|---------| -| `name` | Yes | `String` | Unique key for tool lookup in `context.tools[name]` | -| `label` | Yes | `String` | Human-readable name for display | -| `description` | Yes | `String` | Shown to LLM for tool selection decisions | -| `inputSchema` | Yes | `Dict{String,Any}` | JSON Schema (MCP format) with `type`, `properties`, `required` | -| `execute` | Yes | `Function` | The actual tool logic (see signature below) | -| `prepareArguments` | No | `Function` | Transforms args before validation; `(args::Dict) -> Dict` | -| `validateRequiredArgs` | No | `Function` | Custom validation; `(args::Dict) -> Union{Nothing, String}` | -| `parallelToolExecute` | No | `Bool` | Default `false`. When `true`, allows parallel execution | - -### Execute Function Signature - -```julia -execute(toolCallId::String, - args::AbstractDict{String, Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult -``` - -| Parameter | Description | -|-----------|-------------| -| `toolCallId` | Unique ID from the LLM's tool call (e.g., `"call_abc123"`) | -| `args` | Validated arguments from the LLM, already passed through `prepareArguments` and `validateRequiredArgs` | -| `signal` | Optional `abortSignal` for cancellable operations. Check `signal.aborted` to abort early. | -| `onPartialResult` | Callback for streaming progress: `onPartialResult(partial_data)` emits `toolExecUpdateEvent` | - -### Returns — `agentToolResult` - -**Source:** `type.jl:429-434` - -```julia -struct agentToolResult - content::Vector{messageContent} # Output content (textContent, etc.) - details::Dict{Any,Any} # Tool-specific metadata (e.g., counts, IDs) - usage::Union{llmUsage, Nothing} # Token usage tracking (optional) - terminate::Bool # If true, signals the tool requested loop termination -end -``` - -The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-call-batches--termination-logic) for details. - ---- - -## 4. Tool Registration — Static Registration - -**Source:** `toolRegistry.jl`, `YiemAgent.jl` - -### How `toolStore` Works - -The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. - -```julia -struct toolStore - tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration - name::String # identifier for debugging/logs -end -``` - -`store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. - -### Static Registration — `register_all_tools()` - -```julia -function register_all_tools(store::toolStore)::OrderedDict{String, agentTool} -``` - -**Source:** `YiemAgent.jl:20-29` - -1. **Calls each tool's definition function** — `getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs -2. **Registers each tool** via `registerTool(store, tool)` -3. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime - -### Why Static? - -Tools are **statically included** in `YiemAgent.jl` via `include()`. This means: -- Tool functions live in the `YiemAgent` module, not in dynamically created submodules -- No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world) -- Simpler tool definition — no need to wrap in a `module ... end` block -- Better compiler optimization (inlining, type inference) - -### Registration API - -```julia -# Create per-agent stores -store1 = toolStore(name="agent1") -store2 = toolStore(name="agent2") - -# Load all tools (auto-registers listTools) -tools1 = register_all_tools(store1) # all agents get the same tools -tools2 = register_all_tools(store2) - -# Manual registration (per-store) -registerTool(store1, my_tool) - -# Query (returns OrderedDict keyed by tool name, in registration order) -all_tools = getTools(store1) # OrderedDict{String, agentTool} — O(1) lookup + deterministic order - -# Clear (per-store) -clearTools(store1) # only clears store1 -``` - -`getTools(store)` returns the internal `OrderedDict` directly, giving callers: -- O(1) lookup by tool name -- Deterministic iteration order (registration order) -- Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) -- No copy overhead — mutations on the returned value affect the store - -### Per-Agent Isolation - -Each `toolStore` is completely independent — tools registered in one store do not appear in another: - -```julia -storeA = toolStore(name="A") -storeB = toolStore(name="B") - -registerTool(storeA, getTimeTool()) -registerTool(storeB, getWeatherTool()) - -getTools(storeA) # only contains getTime -getTools(storeB) # only contains getWeather - -clearTools(storeA) # storeB is unaffected -``` - -This ensures that `yiemAgent` instances with different `tool_store` references operate with completely isolated tool sets. - ---- - -## 5. The Agent Loop — High-Level Flow - -**Source:** `agentCore.jl:35-145` - -The `_agentLoop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. - -### Channel Architecture - -``` -yiemAgent struct contains: - - inputChannel (Channel, capacity 16) ← user sends messages here via promptAgent() - - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() -``` - -### Loop States - -The loop tracks 6 states (documented at `agentCore.jl:39-75`): - -| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | -|-------|-----------------|-------------|----------------|-------------------|----------| -| 1 | `nothing` | `false` | empty | empty | Idle, waiting | -| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_processMessage` | -| 3 | running | `true` | empty | empty | Processing, no new input | -| 4 | running | `true` | has msg | empty | New message while busy → queued | -| 5 | running | `true` | empty | has msg | Follow-up while busy → queued | -| 6 | done | `false` | empty | empty | Task completed → send result, reset | - -### Loop Logic (simplified) - -```julia -function _agentLoop(agent::yiemAgent) - while true - # 1. Wait for message from inputChannel (blocking poll) - msg = fetch!(agent.inputChannel) # agentCore.jl:84 - - # 2. Handle shutdown signal - if msg === :shutdown - drain both channels, break loop - end - - # 3. If agent is idle, spawn _processMessage - if agent._state.activeRun == false - processingTask = Threads.@spawn _processMessage(agent) - agent._state.activeRun = true - end - - # 4. While processing: check for followUp messages - if istaskdone(processingTask) == false && isready(agent.followUpChannel) - drain followUpChannel → push to inputChannel - continue # wait for current processing to finish - end - - # 5. When processing completes - if istaskdone(processingTask) == true - result = fetch(processingTask) - put!(agent.outputChannel, result) - agent._state.activeRun = false - processingTask = nothing - end - end -end -``` - -**Key design:** The loop always checks `inputChannel` before `followUpChannel`. Follow-up messages are merged into `inputChannel` only when the current processing task is active, ensuring they are processed after the primary message completes but before new input arrives. - ---- - -## 6. Message Processing Pipeline - -**Source:** `agentCore.jl:175-311` - -`_processMessage(agent)` is the core function that processes a batch of user messages through the LLM pipeline. - -### Pipeline Steps - -```julia -function _processMessage(agent::yiemAgent)::assistantMessage - final_response = nothing - - while true # Loop until LLM returns response without tool calls - # ── Step 1: Drain inputChannel ────────────────────────────── - while isready(agent.inputChannel) - raw_msg = take!(agent.inputChannel) - if raw_msg === :shutdown - put!(agent.inputChannel, :shutdown) - break - end - user_msg = OpenAiToUserMessage(raw_msg) # Convert Dict → userMessage - push!(agent._state.messages, user_msg) - end - - # ── Step 2: Prepare context ───────────────────────────────── - state = agentState(systemPrompt, nothing, tools, messages) - preparedContext = prepareContext(state, eventSink) - # Default: deep copies systemPrompt, messages, tools from agentState → agentContext - # Override point: filter tools, inject context, modify system prompt - - # ── Step 3: Format for LLM ────────────────────────────────── - formattedMessages = formatMsgForLLM(preparedContext, eventSink) - # Converts agentContext → Dict("messages" => [...]) in OpenAI format - # Wraps systemPrompt as system role, converts each messageContent block - - # ── Step 4: Call LLM ──────────────────────────────────────── - response = llmCall(formattedMessages) - # Returns assistantMessage with content::Vector{messageContent} - # Each content block has a type: "text", "thinking", or "tool_call" - - # ── Step 5: Extract tool calls ────────────────────────────── - hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - # Inspects content blocks for "tool_calls" or "tool_call" Dict entries - - # ── Step 6: Execute tool calls or return ──────────────────── - if hasToolCalls && !isempty(toolCallList) - # Build context and config - context = agentContext(systemPrompt, messages, tools) - config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") - signal = abortSignal(false) - - # Execute tool calls (sequential or parallel) - batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, eventSink) - - # Save results to conversation history - for tool_result in batch.messages - push!(messages, tool_result) - end - - # Check termination - if batch.terminate - final_response = build_final_response(batch) - break - end - # Otherwise, loop back to Step 1 (drain any new input) and call LLM again - else - # No tool calls — this is the final response - final_response = response - break - end - end - - return final_response -end -``` - ---- - -## 7. Tool Call Extraction from LLM Response - -**Source:** `agentCore.jl:217-245` - -After the LLM call, the agent inspects `response.content` (a `Vector{messageContent}`) for tool call blocks. Two formats are supported: - -### Format 1: OpenAI `tool_calls` array - -```julia -# Response content block: -Dict( - :type => "tool_calls", - :tool_calls => [ - Dict(:id => "call_1", :name => "getWeather", :arguments => Dict("city" => "Tokyo")), - Dict(:id => "call_2", :name => "getTime", :arguments => Dict("timezone" => "Asia/Tokyo")), - ] -) -``` - -### Format 2: Single `tool_call` block - -```julia -Dict( - :type => "tool_call", - :id => "call_1", - :name => "getWeather", - :arguments => Dict("city" => "Tokyo") -) -``` - -### Extraction Logic - -```julia -tool_call_list = agentToolCall[] - -for content_block in response.content - if content_block isa Dict - # OpenAI format: array of tool calls - if get(content_block, :type, "") == "tool_calls" - for tc_data in get(content_block, :tool_calls, []) - tc = agentToolCall( - type = "function", - id = get(tc_data, :id, string(uuid4())), # fallback UUID - name = get(tc_data, :function, Dict())[:name], - arguments = get(tc_data, :function, Dict())[:arguments], - ) - push!(tool_call_list, tc) - end - # Alternative format: single tool call - elseif get(content_block, :type, "") == "tool_call" - tc_data = content_block - tc = agentToolCall( - type = "function", - id = get(tc_data, :id, string(uuid4())), - name = get(tc_data, :name, ""), - arguments = get(tc_data, :arguments, Dict{String,Any}()), - ) - push!(tool_call_list, tc) - end - end -end -``` - -The `agentToolCall` struct is defined at `type.jl:362-367`: - -```julia -struct agentToolCall - type::String # Always "function" - id::String # Unique tool call identifier - name::String # Tool name (matches context.tools keys) - arguments::Dict{String, Any} # Parsed tool arguments -end -``` - ---- - -## 8. The Per-Call Pipeline — Prepare, Execute, Finalize - -This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases. - -### 8.1 Phase 1: Prepare — `prepareToolCall()` - -**Source:** `agentCore.jl:511-547` - -```julia -function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, -)::Union{preparedToolCall, immediateOutcome} -``` - -**Steps:** - -1. **Resolve tool by name** — `get(context.tools, toolCall.name, nothing)` - - If `nothing` → `immediateOutcome(createErrorToolResult("Tool X not found"), true)` - -2. **Prepare arguments** — `prepareToolCallArguments(tool, toolCall)` - - Calls `tool.prepareArguments(toolCall.arguments)` if defined - - Returns the toolCall with transformed arguments - - If no hook or no change, returns original `toolCall` - -3. **Validate arguments** — `validateToolArguments(tool, prepared)` - - Calls `tool.validateRequiredArgs(prepared.arguments)` if defined, otherwise uses default `validateRequiredArgs(prepared.arguments, tool.inputSchema)` - - Default validator checks `inputSchema["required"]` array - - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below - -4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` - - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal` - - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` - - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` - -5. **Return success** → `preparedToolCall(tool, toolCall, validatedArgs)` - -**Source:** `type.jl:681-685` — `preparedToolCall` holds the resolved tool, original call metadata, and validated arguments together. - -**Key design principle:** Preparation **never throws**. Every failure path returns an `immediateOutcome` with `isError=true`, ensuring the agent loop always has a valid result to feed back to the LLM. - -### 8.2 Phase 2: Execute — `executePreparedToolCall()` - -**Source:** `agentCore.jl:589-617` - -```julia -function executePreparedToolCall( - prep::preparedToolCall, - signal::Union{Nothing, abortSignal}, - eventSink, -)::executedOutcome -``` - -**Steps:** - -1. **Call `tool.execute()`:** - ```julia - result = prep.tool.execute( - prep.toolCall.id, - prep.args, - signal, - eventSink # serves as onPartialResult callback - ) - return executedOutcome(result, false) - ``` - -2. **On error:** - ```julia - catch err - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end - ``` - -### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` - -**Source:** `agentCore.jl:675-706` - -```julia -function finalizeExecutedToolCall( - context::agentContext, - assistantMsg::assistantMessage, - prep::preparedToolCall, - executed::executedOutcome, - config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, -)::finalizedOutcome -``` - -**Steps:** - -1. **Extract execution result:** - ```julia - result = executed.result - isError = executed.isError - ``` - -2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - - Hook can mutate the result: - ```julia - after = config.afterToolCall(afterToolCallContext(...)) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - ``` - - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic - - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` - -3. **Return:** - ```julia - return finalizedOutcome(prep.toolCall, result, isError) - ``` - -**Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. - ---- - -## 9. Execution Modes — Sequential vs Parallel - -**Source:** `agentCore.jl:795-936, 988-1011` - -### Dispatcher — `executeToolCalls()` - -```julia -function executeToolCalls( - context::agentContext, - assistantMsg::assistantMessage, - toolCalls::Vector{agentToolCall}, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, - eventSink, -)::agentToolCallBatch -``` - -**Decision logic** (`agentCore.jl:997-1010`): - -```julia -hasSequential = false -for tc in toolCalls - t = get(context.tools, tc.name, nothing) - if t !== nothing && !t.parallelToolExecute - hasSequential = true - break - end -end - -if config.toolExecution == "sequential" || hasSequential - return executeToolCallsSequential(...) -else - return executeToolCallsParallel(...) -end -``` - -**Rule:** If the global config is `"sequential"` **OR** any tool in the batch has `parallelToolExecute = false`, the entire batch runs sequentially. Sequential is the safe default. - -### Sequential Execution — `executeToolCallsSequential()` - -**Source:** `agentCore.jl:795-829` - -```julia -function executeToolCallsSequential(...)::agentToolCallBatch - finalizedCalls = finalizedOutcome[] - messages = toolResultMessage[] - - for tc in toolCalls - prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - else - executed = executePreparedToolCall(prep, signal, eventSink) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - end - - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - - if signal !== nothing && signal.aborted - break # abort: skip remaining calls - end - end - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) -end -``` - -### Parallel Execution — `executeToolCallsParallel()` - -**Source:** `agentCore.jl:888-936` - -```julia -function executeToolCallsParallel(...)::agentToolCallBatch - entries = union{finalizedOutcome, task{finalizedOutcome}}[] - - for tc in toolCalls - prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - push!(entries, finalized) # immediate outcome — no task - else - task = task() do - executed = executePreparedToolCall(prep, signal, eventSink) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - return finalized - end - schedule(task) - push!(entries, task) # pending task - end - - if signal !== nothing && signal.aborted - break # abort during preparation — skip remaining - end - end - - # Collect results in original order - finalizedCalls = finalizedOutcome[] - for entry in entries - outcome = entry isa task ? fetch(entry) : entry - push!(finalizedCalls, outcome) - end - - messages = toolResultMessage[] - for f in finalizedCalls - push!(messages, createToolResultMessage(f)) - end - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) -end -``` - -**Key design:** All tool calls are **prepared** sequentially (validation, hooks), then prepared calls are **executed** concurrently as separate tasks. Results are collected in the original call order via `fetch()`. - -**Trade-off:** Parallel execution reduces wall-clock time for independent tools but can overwhelm external resources (rate limits, connection pools, disk I/O). - ---- - -## 10. Tool Call Batches & Termination Logic - -**Source:** `type.jl:793-838` - -### `agentToolCallBatch` - -```julia -struct agentToolCallBatch - messages::Vector{toolResultMessage} # Tool result messages for this batch - terminate::Bool # Whether the batch should terminate the loop -end -``` - -### Termination Logic — `shouldTerminate()` - -**Source:** `agentCore.jl:409-411` - -```julia -function shouldTerminate(batches::Vector{finalizedOutcome})::Bool - return !isempty(batches) && all(b -> b.result.terminate, batches) -end -``` - -**Rule:** `terminate` is `true` **only when every tool in the batch** has `result.terminate == true`. This prevents a single tool that sets `terminate: true` (e.g., for metadata purposes) from accidentally stopping the agent when other tools did not intend to terminate. - -### When to Set `terminate = true` - -From the type documentation (`type.jl:803-815`): - -| Use Case | Description | -|----------|-------------| -| Task completion | A tool like `deploy` or `submit` finishes its work and signals the agent to stop | -| Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | -| Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | - -### Batch Processing in `_processMessage()` - -**Source:** `agentCore.jl:266-307` - -```julia -batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink) - -# Save results to conversation history -for tool_result in batch.messages - push!(agent._state.messages, tool_result) -end - -if batch.terminate - # Build final response from tool results - final_content = [textContent("Tool execution completed.")] - for tool_result in batch.messages - for content_block in tool_result.content - if content_block isa textContent - append!(final_content, [content_block]) - elseif content_block isa Dict - if haskey(content_block, :text) - push!(final_content, textContent(content_block[:text])) - end - end - end - end - final_response = assistantMessage( - role = "assistant", - content = final_content, - api = response.api, - model = response.model, - usage = response.usage, - stopReason = "tool_use_terminated", - errorMessage = if any(x -> x.isError, batch.messages) - "One or more tool calls failed" - else - nothing - end, - timestamp = now(), - ) - break # exit the while loop -end -# If batch.terminate == false, loop back to call LLM again with tool results -``` - ---- - -## 11. Tool Result Message Creation - -**Source:** `agentCore.jl:373-379` - -### `createToolResultMessage()` - -```julia -function createToolResultMessage(f::finalizedOutcome)::toolResultMessage - return toolResultMessage( - "toolResult", # role - f.toolCall.id, # toolCallId - f.toolCall.name, # toolName - f.result.content, # content (Vector{messageContent}) - f.result.details, # details - f.result.usage, # usage - nothing, # addedToolNames (for dynamic tools) - f.isError, # isError - now(), # timestamp - ) -end -``` - -### `toolResultMessage` Struct - -**Source:** `type.jl:152-191` - -```julia -struct toolResultMessage <: agentMessage - role::String # Always "tool" - toolCallId::String # ID matching the original tool call - toolName::String # Name of the executed tool - content::Vector{messageContent} # Tool output content - details::Any # Additional tool-specific details - usage::Union{llmUsage, Nothing} # Token usage if applicable - addedToolNames::Union{Vector{String}, Nothing} # Tools added during execution - isError::Bool # Whether the tool call resulted in an error - timestamp::Timestamp # When the result was recorded -end -``` - -### Conversation History After Tool Execution - -``` -[system] "You are a helpful assistant." -[user] "What's the weather in Tokyo?" -[assistant] {tool_calls: [getWeather(city="Tokyo")]} -[tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" -``` - -On the next loop iteration, `formatMsgForLLM()` converts `toolResultMessage` to OpenAI format: - -```julia -Dict( - "role" => "tool", - "tool_call_id" => "call_1", - "content" => [Dict("type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C")] -) -``` - ---- - -## 12. Error Handling & Recovery Pattern - -The framework uses a **result-based error handling** pattern instead of exceptions for tool call failures. This ensures the LLM always receives a tool result message, giving it the information to recover. - -### Error Flow - -``` -Tool call fails at any phase - │ - ▼ -┌─────────────────────────┐ -│ Phase: Prepare │ → immediateOutcome(error_result, true) -│ Phase: Execute │ → executedOutcome(error_result, true) -│ Phase: Finalize (hook) │ → finalizedOutcome(tc, error_result, true) -└─────────────────────────┘ - │ - ▼ -createToolResultMessage(finalized) - │ - ▼ -push!(agent._state.messages, toolResultMessage) - │ - ▼ -formatMsgForLLM() → LLM receives error as tool result - │ - ▼ -LLM can: retry with corrected args, report failure, or ask user for clarification -``` - -### `createErrorToolResult()` - -**Source:** `agentCore.jl:339-341` - -```julia -function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) -end -``` - -Returns an `agentToolResult` with a single `textContent` block containing the error message. This is wrapped in an `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` depending on where the error occurred, then converted to `toolResultMessage` for the LLM. - -**Key design:** Returning a result instead of throwing allows the LLM to see the error and decide whether to retry, re-issue the call with different arguments, or report failure to the user. - ---- - -## 13. Event System — Tool Lifecycle Events - -**Source:** `type.jl:480-516` - -Each tool call emits a three-event lifecycle: - -``` -toolExecStartEvent → [zero or more toolExecUpdateEvent] → toolExecEndEvent -``` - -### Event Types - -```julia -struct toolExecStartEvent - toolCallId::String # ID of the tool call - toolName::String # Name of the tool - arguments::Dict{String, Any} # Tool arguments -end - -struct toolExecUpdateEvent - toolCallId::String # ID of the tool call - toolName::String # Name of the tool - arguments::Dict{String, Any} # Tool arguments - partialResult::Any # The partial result data -end - -struct toolExecEndEvent - toolCallId::String # ID of the tool call - toolName::String # Name of the tool - result::agentToolResult # The final tool result - isError::Bool # Whether execution resulted in an error -end -``` - -### Event Emission Points - -| Event | Emitted From | When | -|-------|-------------|------| -| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | -| `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | -| `toolExecEndEvent` | `finalizeExecutedToolCall()` | After finalization for each tool call | - -### Event Sink - -The `eventSink` function is passed through the entire call chain: - -```julia -eventSink = agent.eventSink # set during yiemAgent construction -``` - -The `eventSink` function is a user-provided callback that receives all events. This is typically used by: -- **TUI (Terminal UI):** Display real-time progress, tool names, results -- **Logging systems:** Record tool execution history -- **Monitoring:** Track tool usage, execution times, error rates -- **Audit trails:** Log all tool calls with arguments and results - ---- - -## 14. Agent Lifecycle Hooks - -### Hook Types - -| Hook | Signature | Called | Purpose | -|------|-----------|--------|---------| -| `prepareContext` | `(state::agentState, eventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | -| `formatMsgForLLM` | `(ctx::agentContext, eventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format | -| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | -| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | -| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | -| `eventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | - -### `beforeToolCall` Hook - -**Source:** `agentCore.jl:530-541` - -```julia -if config.beforeToolCall !== nothing - before = config.beforeToolCall( - beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal - ) - if signal !== nothing && signal.aborted - return immediateOutcome(createErrorToolResult("Operation aborted"), true) - end - if before !== nothing && before.block - return immediateOutcome( - createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) - end -end -``` - -**Return values:** -- `nothing` — proceed with execution -- `Dict(:block => true, :reason => "...")` — block execution, error fed back to LLM -- `Dict(:block => false)` — proceed (explicit allow) - -### `afterToolCall` Hook - -**Source:** `agentCore.jl:687-703` - -```julia -if config.afterToolCall !== nothing - try - after = config.afterToolCall( - afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal - ) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true - end -end -``` - -**Common use cases:** -- Mask sensitive data from result content before the LLM sees it (e.g., removing API keys from error messages) -- Normalize usage tracking data into a consistent format -- Inspect the result and decide to flip `terminate: true` based on business logic -- Wrap an error result in a friendlier message for the LLM to understand - -### `prepareContext` Hook - -**Source:** `utils.jl:111-125` - -```julia -function prepareContext(state::agentState, eventSink)::agentContext - # TODO: filter tools from state.tools based on user intent - filteredTools = state.tools - - # TODO: add filtered tools to the current system prompt / modify systemPrompt - preparedSystemPrompt = state.systemPrompt - - # TODO: add system prompt, adjust/modify and inject additional context into messages - preparedMessages = deepcopy(state.messages) - - return agentContext(preparedSystemPrompt, preparedMessages, filteredTools) -end -``` - -**Override points for customization:** -- Filter tools based on user intent (e.g., only show "wine" tools when user asks about wine) -- Modify the system prompt dynamically (e.g., inject current time, user preferences) -- Inject additional context (e.g., retrieved documents, current user state) -- Prune or reorder messages before formatting for the LLM - -### `formatMsgForLLM` Hook - -**Source:** `utils.jl:158-219` - -Default implementation converts `agentContext` to OpenAI-compatible format: - -```julia -function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any} - messages = Vector{Dict{String, Any}}() - - # System prompt as system message - if !isempty(ctx.systemPrompt) - push!(messages, Dict( - "role" => "system", - "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] - )) - end - - # Conversation messages - for msg in ctx.messages - if msg isa userMessage - push!(messages, _userMessageToOpenAI(msg)) - elseif msg isa assistantMessage - push!(messages, _assistantMessageToOpenAI(msg)) - elseif msg isa toolResultMessage - push!(messages, _toolResultMessageToOpenAI(msg)) - end - end - - return Dict("messages" => messages) -end -``` - -Override this to produce custom LLM message formats for different APIs/providers (e.g., Anthropic, Google, Ollama). - ---- - -## 15. Self-Modifying Tools - -The framework supports tools that modify the tool system itself at runtime. - -### `writeTool` — Create New Tool Files - -**Source:** `tools/writeTool.jl` - -`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (Julia code body), and `writeTool` wraps it in Julia boilerplate: -1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal -2. Indents `executeCode` with 4 spaces -3. Wraps it inside `function executeTool(...)::agentToolResult ... end` -4. Appends `writeToolTool()` returning an `agentTool` struct -5. Writes the combined string to `src/tools/.jl` - -### `listTool` — Discover Available Tools - -**Source:** `toolRegistry.jl:43-98` - -Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. - -### Self-Tooling Workflow - -``` -1. Agent detects no existing tool handles the user's request -2. Agent calls writeTool with: - - name: "searchWine" - - label: "Wine Search" - - description: "Search a wine database..." - - inputSchema: { ... } - - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - - (optional) validateCode, prepareCode -3. writeTool generates src/tools/searchWine.jl -4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl -5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl -6. Developer restarts Julia — new tool is loaded -7. Agent calls searchWine(query="cabernet") -8. Result: "Found 5 cabernet wines..." -``` - -### `writeTool` Input Schema - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | `String` | Yes | Valid Julia identifier (letters, digits, underscores) | -| `label` | `String` | Yes | Human-readable tool name | -| `description` | `String` | Yes | What the tool does | -| `inputSchema` | `Dict` | Yes | JSON Schema in MCP format | -| `executeCode` | `String` | Yes | Julia code for `executeTool` body (NOT wrapped in function) | -| `validateCode` | `String` | No | Custom validation Julia code | -| `prepareCode` | `String` | No | Argument preparation code | -| `parallel` | `Bool` | No | Whether the tool can run in parallel (default: `false`) | - ---- - -## 16. Complete End-to-End Example - -### Full Lifecycle: User Message to Tool Result - -``` -USER SENDS MESSAGE - └─> promptAgent(agent, "What's the weather in Tokyo?") - └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) - - -LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL - └─> _agentLoop: detects msg in inputChannel - └─> @spawn _processMessage(agent) - - ── _processMessage ────────────────────────────────────────────── - │ - │ Step 1: Drain inputChannel - │ raw_msg = Dict("role" => "user", "content" => [...]) - │ user_msg = OpenAiToUserMessage(raw_msg) - │ push!(agent._state.messages, user_msg) - │ - │ Step 2: prepareContext - │ ctx = agent.prepareContext(agent._state) - │ → agentContext(systemPrompt, messages, tools) - │ - │ Step 3: formatMsgForLLM - │ formatted = agent.formatMsgForLLM(ctx) - │ → Dict("messages" => [ - │ Dict("role" => "system", "content" => [...]), - │ Dict("role" => "user", "content" => [...]), - │ ]) - │ - │ Step 4: llmCall - │ response = agent.llmCall(formatted) - │ → assistantMessage(content = [ - │ Dict(:type => "tool_calls", :tool_calls => [ - │ Dict(:id => "call_1", :name => "getWeather", - │ :arguments => Dict("city" => "Tokyo")) - │ ]) - │ ]) - │ - │ Step 5: Extract tool calls - │ tool_call_list = [agentToolCall("function", "call_1", "getWeather", ...)] - │ - │ Step 6: Execute tool calls - │ context = agentContext(systemPrompt, messages, tools) - │ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") - │ batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink) - - -LOOP ITERATION 1 — executeToolCallsSequential - │ - │ For tc = agentToolCall("call_1", "getWeather", ...): - │ - │ PREPARE: - │ tool = context.tools["getWeather"] → found! - │ validatedArgs = validateToolArguments(tool, tc) - │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes - │ beforeToolCall_hook(...) → nothing (skipped) - │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) - │ - │ EXECUTE: - │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, eventSink) - │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) - │ → executedOutcome(result, false) - │ - │ FINALIZE: - │ afterToolCall_hook(...) → nothing (skipped) - │ → finalizedOutcome(tc, result, false) - │ - │ msg = createToolResultMessage(finalized) - │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) - │ - └─> agentToolCallBatch([msg], false) - │ - │ Save results: - │ for tool_result in batch.messages - │ push!(agent._state.messages, tool_result) - │ end - │ - │ batch.terminate == false → loop back to Step 1 - │ - - -LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE - ── _processMessage (second iteration) ─────────────────────────── - │ - │ Step 1: Drain inputChannel → empty - │ - │ Step 2-3: prepareContext → formatMsgForLLM - │ → messages now include: - │ [system] "You are a helpful assistant." - │ [user] "What's the weather in Tokyo?" - │ [tool] tool_call_id="call_1", content="Weather in Tokyo: Sunny, 22°C" - │ - │ Step 4: llmCall - │ → assistantMessage(content = [Dict(:type => "text", :text => "The weather in Tokyo is sunny, 22°C.")]) - │ - │ Step 5: Extract tool calls → none - │ - │ Step 6: has_tool_calls == false → break, return final_response - │ - └─> return final_response - - -AGENT LOOP: SEND RESPONSE TO USER - └─> put!(agent.outputChannel, final_response) - └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") -``` - ---- - -## 17. Tool File Contract - -Each `.jl` file in `src/tools/` follows a flat, static structure: - -### Required Function - -```julia -function Tool()::agentTool - # Must return an agentTool instance -end -``` - -### Optional Functions - -```julia -# Argument preparation (before validation) -function PrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any} - # Return modified args, or args unchanged - return args -end - -# Custom validation (before execution) -function ValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String} - # Return nothing to pass, or error string to fail - return nothing -end - -# Core execution -function Execute(toolCallId::String, - args::AbstractDict{String, Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult - # Return agentToolResult with content, details, usage, terminate - return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false) -end -``` - -### File Structure - -```julia -# src/tools/myTool.jl - -using .type # ← provides agentTool, textContent, agentToolResult, etc. -using Dates # ← tool's own dependencies - -# Optional: helper functions -function helper_function(...) - ... -end - -# Optional: prepareArguments -function myToolPrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any} - return args -end - -# Optional: validateRequiredArgs -function myToolValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String} - return nothing -end - -# Required: execute function -function myToolExecute(toolCallId::String, args::AbstractDict{String, Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult - ... -end - -# Required: getTool function -function myToolTool()::agentTool - return agentTool( - name = "myTool", - label = "My Tool", - description = "What this tool does", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict(...), - "required" => [...] - ), - execute = myToolExecute, - prepareArguments = myToolPrepareArguments, - validateRequiredArgs = myToolValidateRequiredArgs, - parallelToolExecute = false - ) -end -``` - -### Dependencies - -Each tool file declares its own dependencies via `using` statements: - -```julia -# src/tools/getTime.jl -using .type -using Dates - -function getTimeExecute(...) - now() # Dates.now requires `using Dates` -end -``` - -```julia -# src/tools/myApiTool.jl -using .type -using HTTP, JSON - -function myApiToolExecute(...) - response = HTTP.get("https://api.example.com") - data = JSON.parse(String(response.body)) - ... -end -``` - -### Why Flat Modules? - -All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means: -- All functions live in the `YiemAgent` module, avoiding world-age issues -- `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available -- Functions are named with a `` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`) -- The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration - ---- - -## 18. Adding New Tools - -To add a new tool (e.g., `searchWine.jl`): - -### Step 1: Create `src/tools/searchWine.jl` - -```julia -using .type -# using AdditionalPkg # add if needed - -function searchWineExecute(toolCallId::String, args::AbstractDict{String, Any}, - signal::Union{Nothing,abortSignal}, onPartialResult) - query = get(args, "query", "") - result = search_wine_db(query) - return agentToolResult( - [textContent("Found $(length(result)) wines")], - Dict{Any,Any}("count" => length(result)), - nothing, false - ) -end - -function searchWineTool()::agentTool - return agentTool( - name = "searchWine", - label = "Search Wine", - description = "Search wine database...", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string", "description" => "Search query") - ), - "required" => ["query"] - ), - execute = searchWineExecute, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -``` - -### Step 2: Include in `src/YiemAgent.jl` (before `toolRegistry.jl`) - -```julia -include("tools/getWeather.jl") -include("tools/getTime.jl") -include("tools/searchWine.jl") # ← add here -include("tools/writeTool.jl") -``` - -### Step 3: Register in `register_all_tools()` in `YiemAgent.jl` - -```julia -function register_all_tools(store::toolRegistry.toolStore) - registerTool(store, getWeatherTool()) - registerTool(store, getTimeTool()) - registerTool(store, searchWineTool()) # ← add here - registerTool(store, writeToolTool()) - registerTool(store, listTool(store)) - return store.tools -end -``` - -### Step 4: Restart Julia - -The module recompiles on next load. The new tool is available immediately. - ---- - -## 19. Appendix: Type Reference - -### Message Types - -| Type | Source | Description | -|------|--------|-------------| -| `messageContent` | `type.jl:67` | Abstract base for message content | -| `textContent` | `type.jl:69` | Plain text content (`text::String`) | -| `imageContent` | `type.jl:73` | Image content (`data::String`, `mimeType::String`) | -| `agentMessage` | `type.jl:82` | Abstract base for all messages | -| `userMessage` | `type.jl:84` | User message (`role`, `content`, `timestamp`) | -| `assistantMessage` | `type.jl:111` | LLM response (`role`, `content`, `api`, `provider`, `model`, `usage`, `stopReason`, `errorMessage`, `timestamp`) | -| `toolResultMessage` | `type.jl:152` | Tool result (`role`, `toolCallId`, `toolName`, `content`, `details`, `usage`, `addedToolNames`, `isError`, `timestamp`) | - -### Tool Types - -| Type | Source | Description | -|------|--------|-------------| -| `agentTool` | `type.jl:261` | Tool definition (name, label, description, schema, execute, hooks) | -| `agentToolCall` | `type.jl:362` | Tool call from LLM (type, id, name, arguments) | -| `agentToolResult` | `type.jl:429` | Tool execution result (content, details, usage, terminate) | -| `agentToolCallBatch` | `type.jl:835` | Batch of tool results (messages, terminate) | - -### Lifecycle Outcome Types - -| Type | Source | Description | -|------|--------|-------------| -| `preparedToolCall` | `type.jl:681` | After prepare: (tool, toolCall, args) | -| `immediateOutcome` | `type.jl:719` | Failed before execution: (result, isError) | -| `executedOutcome` | `type.jl:753` | After execute, before finalize: (result, isError) | -| `finalizedOutcome` | `type.jl:787` | After all phases: (toolCall, result, isError) | - -### Context & Config Types - -| Type | Source | Description | -|------|--------|-------------| -| `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | -| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | -| `agentLoopConfig` | `type.jl:403` | Loop config (beforeToolCall, afterToolCall, toolExecution) | -| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | -| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | -| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | - -### Event Types - -| Type | Source | Description | -|------|--------|-------------| -| `toolExecStartEvent` | `type.jl:480` | (toolCallId, toolName, arguments) | -| `toolExecUpdateEvent` | `type.jl:495` | (toolCallId, toolName, arguments, partialResult) | -| `toolExecEndEvent` | `type.jl:511` | (toolCallId, toolName, result, isError) | - -### Agent Types - -| Type | Source | Description | -|------|--------|-------------| -| `agent` | `type.jl:522` | Abstract base type | -| `yiemAgent` | `type.jl:527` | High-level agent wrapper (state, channels, callbacks, task) | diff --git a/my_AI_prompt.md b/my_AI_prompt.md new file mode 100644 index 0000000..b9d0f8b --- /dev/null +++ b/my_AI_prompt.md @@ -0,0 +1 @@ +read this code \ No newline at end of file diff --git a/src/agentCore.jl b/src/agentCore.jl index b6f33d4..b587e67 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -44,81 +44,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Each block has a type — "text", "thinking", or "toolCall". # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). llmCall - - # Callable struct for MCP server communication. - # Called as: mcpServer("tools/list") → returns JSON-RPC 2.0 parsed response - # mcpServer("tools/call", toolName, arguments) → returns JSON-RPC 2.0 parsed response - # - # # Example (weather tool) - # # User provides a callable struct - # struct MyMCPClient - # natsConn::NATS.Connection - # topic::String - # senderID::String - # fileserver_url::String - # end - # - # function (c::MyMCPClient)(method::String) - # payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}()) - # payloads = [("payload", payload, "dictionary")] - # _, msg_envelope_json_str = msghandler.smartpack( - # c.topic, payloads; sender_id=c.senderID, - # msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url) - # reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) - # incoming_env = msghandler.smartunpack(String(reply.payload)) - # return incoming_env["payloads"][1][2] - # end - # - # function (c::MyMCPClient)(method::String, toolName::String, arguments::AbstractDict{String, Any}) - # payload = Dict("jsonrpc" => "2.0", "id" => 2, "method" => method, - # "params" => Dict("name" => toolName, "arguments" => arguments)) - # payloads = [("payload", payload, "dictionary"),] - # _, msg_envelope_json_str = msghandler.smartpack( - # c.topic, payloads; sender_id=c.senderID, - # msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url) - # reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180) - # incoming_env = msghandler.smartunpack(String(reply.payload)) - # return incoming_env["payloads"][1][2] - # end - # - # # "tools/list" input: - # mcpServer("tools/list") - # # sending out payload before smart packed by msghandler: - # Dict("jsonrpc" => "2.0", "id" => 1, "method" => "tools/list", "params" => Dict{String, Any}()) - # # expected return after smart unpacked by msghandler: - # Dict( - # "jsonrpc" => "2.0", "id" => 1, - # "result" => Dict( - # "tools" => [ - # Dict( - # "name" => "getWeather", - # "description" => "Fetch current weather for a city.", - # "inputSchema" => Dict("type"=>"object", - # "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"), - # "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")), - # "required" => ["city"]) - # ) - # ], - # "nextCursor" => nothing - # ) - # ) - # - # # "tools/call" input: - # mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius")) - # # sending out payload before smart packed by msghandler: - # Dict("jsonrpc" => "2.0", "id" => 2, "method" => "tools/call", - # "params" => Dict("name" => "getWeather", "arguments" => Dict("city"=>"Tokyo", "units"=>"celsius"))) - # # expected return after smart unpacked by msghandler (success): - # Dict("jsonrpc" => "2.0", "id" => 2, - # "result" => Dict("content" => [{"type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C"}], - # "isError" => false)) - # # expected return after smart unpacked by msghandler (tool error): - # Dict("jsonrpc" => "2.0", "id" => 2, - # "result" => Dict("content" => [{"type" => "text", "text" => "Error: City 'Atlantis' not found."}], - # "isError" => true)) - # # expected return after smart unpacked by msghandler (protocol error): - # Dict("jsonrpc" => "2.0", "id" => 2, - # "error" => Dict("code" => -32602, "message" => "Missing required argument 'city'")) + + # check input and output format at MCP server doc mcpServer # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) @@ -1101,7 +1028,7 @@ function executePreparedToolCall( )::executedOutcome eventSink("executePreparedToolCall 1") - try #WORKING + try #WORKING pass sessionId from yiemAgent to tool call result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink) eventSink("executePreparedToolCall 2") eventSink("tool execute raw result: " * sprint(show, result)) diff --git a/src/api.jl b/src/api.jl index 7bc924e..2d06848 100644 --- a/src/api.jl +++ b/src/api.jl @@ -38,6 +38,11 @@ function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) return agent end +function promptAgent(agent::yiemAgent, text::String) + put!(agent.inputChannel, Dict("role" => "user", "content" => text)) + return agent +end + """ Take a response from the agent's output channel. @@ -91,6 +96,11 @@ function followUp(agent::yiemAgent, msg::AbstractDict{String, Any}) return agent end +function followUp(agent::yiemAgent, text::String) + put!(agent.followUpChannel, Dict("role" => "user", "content" => text)) + return agent +end + """ Gracefully stop the agent. diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 5b870ec..9fbe6bc 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -60,48 +60,48 @@ function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSin end return agentTool( - name=name, - label=title, - description=desc, - inputSchema=params, - execute=(toolCallId::String, args::AbstractDict{String, Any}, - signal::Union{Nothing,abortSignal}, - eventSink) -> begin - try - response = mcpserver("tools/call", name, args, sessionId) + name=name, + label=title, + description=desc, + inputSchema=params, + execute=(toolCallId::String, args::AbstractDict{String, Any}, + signal::Union{Nothing,abortSignal}, + eventSink) -> begin + try + response = mcpserver("tools/call", name, args, sessionId) - # Parse JSON-RPC 2.0 response envelope - if haskey(response, "error") - rpc_error = response["error"] - err_msg = get(rpc_error, "message", "Unknown MCP error") - return agentToolResult( - [textContent("MCP error: $err_msg")], - Dict{Any,Any}("error" => err_msg), - nothing, false - ) - end + # Parse JSON-RPC 2.0 response envelope + if haskey(response, "error") + rpc_error = response["error"] + err_msg = get(rpc_error, "message", "Unknown MCP error") + return agentToolResult( + [textContent("MCP error: $err_msg")], + Dict{Any,Any}("error" => err_msg), + nothing, false + ) + end - result_data = get(response, "result", response) - content_text = _extract_text_content(result_data) - is_error = get(result_data, "isError", false) + result_data = get(response, "result", response) + content_text = _extract_text_content(result_data) + is_error = get(result_data, "isError", false) - return agentToolResult( - [textContent(content_text)], - Dict{Any,Any}("isError" => is_error), - nothing, false - ) - catch e - errMsg = sprint(showerror, e) - return agentToolResult( - [textContent("MCP call error: $errMsg")], - Dict{Any,Any}("error" => errMsg), - nothing, false - ) - end - end, - prepareArguments=nothing, - validateRequiredArgs=nothing, - parallelToolExecute=false, + return agentToolResult( + [textContent(content_text)], + Dict{Any,Any}("isError" => is_error), + nothing, false + ) + catch e + errMsg = sprint(showerror, e) + return agentToolResult( + [textContent("MCP call error: $errMsg")], + Dict{Any,Any}("error" => errMsg), + nothing, false + ) + end + end, + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, ) end @@ -139,41 +139,41 @@ function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; ev cursor = get(response, "nextCursor", nothing) for tool_def in tools_array - name = tool_def["name"] - if haskey(tools, name) - continue - end + name = tool_def["name"] + if haskey(tools, name) + continue + end - wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) - tools[name] = wrapped - new_count += 1 + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) + tools[name] = wrapped + new_count += 1 end # Paginate: fetch remaining tools if nextCursor is present while cursor !== nothing && cursor !== "" - response = sessionId !== nothing ? mcpserver("tools/list", sessionId) : mcpserver("tools/list") - if haskey(response, "result") - response = response["result"] - end - tools_array = get(response, "tools", Any[]) - cursor = get(response, "nextCursor", nothing) - for tool_def in tools_array - name = tool_def["name"] - if haskey(tools, name) - continue - end - wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) - tools[name] = wrapped - new_count += 1 + response = sessionId !== nothing ? mcpserver("tools/list", sessionId) : mcpserver("tools/list") + if haskey(response, "result") + response = response["result"] + end + tools_array = get(response, "tools", Any[]) + cursor = get(response, "nextCursor", nothing) + for tool_def in tools_array + name = tool_def["name"] + if haskey(tools, name) + continue end + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) + tools[name] = wrapped + new_count += 1 + end end println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(tools))") catch e bt = catch_backtrace() err_msg = sprint() do io - showerror(io, e, bt) - println(io) + showerror(io, e, bt) + println(io) end eventSink(err_msg) diff --git a/src/utils.jl b/src/utils.jl index 515ee28..9c4c52c 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -96,7 +96,7 @@ or pruning and reordering messages before formatting and calling the LLM. messages, and tools to be sent to the LLM # Examples -```julia +```jldoctest # Default: returns an agentContext with deep copies of system prompt, messages, and tools prepareContext(state).messages == deepcopy(state.messages) @@ -146,7 +146,7 @@ LLM message formats for different APIs/providers. an array of OpenAI-format message dicts # Examples -```julia +```jldoctest # Default output: formatMsgForLLm(ctx) == Dict("messages" => [ Dict("role" => "system", "content" => [...]), @@ -265,7 +265,7 @@ Common use cases: - `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`). # Example -```julia +```jldoctest function beforeToolCall(context::beforeToolCallContext, signal::abortSignal) if context.toolCall.name == "deleteFile" # Block file deletion unless explicitly approved @@ -320,11 +320,106 @@ function afterToolCall(context::afterToolCallContext, signal::abortSignal end -#TODO -function eventSink(x) - +""" +Create an event sink for logging messages and/or publishing to NATS. + +# Fields +- `natsConn`: NATS connection, or `nothing` for file-only sink +- `topic`: NATS topic to publish to, or `nothing` for file-only sink +- `service_name`: Optional prefix added to log entries +- `log_file`: Path to the log file +- `max_log_entries`: Maximum number of entries to keep in the log file + +# Callable interface +```julia + (es::eventSink)(msg::String; log=true, publish=true) -> Nothing +``` +Write a timestamped log entry to file, publish to NATS, or both. + +# Examples +```julia +using NATS + +conn = NATS.connect() + +# With NATS — logs to file and publishes to "agent.events" +es = eventSink(natsConn=conn, topic="agent.events", service_name="agent1") +es("agent started") # both file + NATS (default) +es("task done"; log=false) # NATS only +es("checkpoint"; publish=false) # file only + +# File only — no NATS connection +es2 = eventSink(log_file="./agent.log", service_name="worker") +es2("ready") # file only + +NATS.drain(conn) +``` +""" +struct eventSink + natsConn::Union{NATS.Connection, Nothing} + topic::Union{String, Nothing} + service_name::String + log_file::String + max_log_entries::Int end +eventSink(; natsConn=nothing, topic=nothing, service_name="", log_file="./log/eventSink.md", max_log_entries=100) = + eventSink(natsConn, topic, service_name, log_file, max_log_entries) + +""" + rotate!(es::eventSink) -> Nothing + +Rewrite the log file keeping only the last `max_log_entries` entries. +Call after writing when the entry count reaches the limit. +""" +function rotate!(es::eventSink) + content = read(es.log_file, String) + entries = split(content, r"- \[", keepempty=false) + open(es.log_file, "w") do f + write(f, "- [") + for e in entries[2:end] + write(f, "- [", e) + end + end +end + +""" + (es::eventSink)(msg::String; log=true, publish=true) -> Nothing + +Write a timestamped log entry to file, publish to NATS, or both. + +# Arguments +- `msg`: The message to log or publish. +- `log`: If `true`, append the entry to `es.log_file` (default `true`). +- `publish`: If `true`, publish to the NATS topic (default `true`; no-op if no NATS connection was provided). +""" +function (es::eventSink)(msg::String; log::Bool=true, publish::Bool=true) + timestamp = Dates.format(now(), "yyyy-mm-ddTHH:MM:SS.sss") + svc_prefix = isempty(es.service_name) ? "" : "[$(es.service_name)] " + entry = "- [$timestamp] $svc_prefix$msg\n" + if log + log_dir = dirname(es.log_file) + mkpath(log_dir) + open(es.log_file, "a") do f + write(f, entry) + end + # Rotate after write — keep only last max_log_entries entries + content = read(es.log_file, String) + entries = split(content, r"- \[", keepempty=false) + if length(entries) > es.max_log_entries + rotate!(es) + end + end + if publish && es.topic !== nothing && es.natsConn !== nothing + try + NATS.publish(es.natsConn, es.topic, entry) + catch e + @warn "eventSink NATS publish failed" exception=e + end + end +end + + """ Convert a userMessage to OpenAI message format. @@ -432,7 +527,7 @@ Convert an OrderedDict of agentTool definitions into OpenAI function tool format Returns an empty vector when `tools` is `nothing` or empty. # Examples -```julia +```jldoctest _toolsToOpenAI(nothing) # => Dict{String, Any}[] _toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))] ``` @@ -478,7 +573,7 @@ coercion, format validation, cross-field constraints). - `String` error message listing missing fields otherwise # Examples -```julia +```jldoctest schema = Dict("required" => ["city"]) args = Dict{String,Any}() validateRequiredArgs(args, schema) # => "Missing required arguments: city" @@ -533,7 +628,7 @@ false `toolExecutionStart` events. and converted to an `immediateOutcome` # Examples -```julia +```jldoctest # With validateRequiredArgs hook set on the tool validateToolArguments(toolWithHook, tc) # => validated args or throws -- 2.52.0 From 6db50b6a52ba450355c8e0d721bf9f9268a38482 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 2 Sep 2026 11:17:41 +0700 Subject: [PATCH 103/112] update --- Manifest.toml | 8 +-- Project.toml | 2 +- my_AI_prompt.md | 95 ++++++++++++++++++++++++- src/agentCore.jl | 176 ++++++++++++++++++++++++++--------------------- src/api.jl | 4 +- src/utils.jl | 106 +--------------------------- 6 files changed, 199 insertions(+), 192 deletions(-) diff --git a/Manifest.toml b/Manifest.toml index 5ca0ac4..c58287b 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.7" manifest_format = "2.0" -project_hash = "eeddbbbbefd50bd46b098a2d54b2795d85daa76e" +project_hash = "43252cf86e6030e836ede74c563b58ab2f0f0548" [[deps.AWS]] deps = ["Base64", "Compat", "Dates", "Downloads", "GitHub", "HTTP", "IniFile", "JSON", "MD5", "Mocking", "OrderedCollections", "Random", "SHA", "ScopedValues", "Sockets", "URIs", "UUIDs", "XMLDict"] @@ -334,11 +334,11 @@ version = "1.2.0" [[deps.GeneralUtils]] deps = ["AWS", "AWSS3", "CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "OrderedCollections", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "URIs", "UUIDs"] -git-tree-sha1 = "deb2a19735d937b6265d18f55ce0788ac574e9c7" +git-tree-sha1 = "4d7ff3b02a9cbd6d4f65fea43551b0bca6a83a22" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.6.6" +version = "0.6.9" [[deps.GitHub]] deps = ["Base64", "Dates", "HTTP", "JSON", "MbedTLS", "Sockets", "SodiumSeal", "URIs"] @@ -1126,7 +1126,7 @@ uuid = "228000da-037f-5747-90a9-8195ccbf91a5" version = "0.4.2" [[deps.YiemAgent]] -deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "Serialization", "URIs", "UUIDs"] +deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "Serialization", "URIs", "UUIDs", "msghandler"] path = "." uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" version = "0.8.0" diff --git a/Project.toml b/Project.toml index 62dde12..3f8466f 100644 --- a/Project.toml +++ b/Project.toml @@ -26,7 +26,7 @@ msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10" Base64 = "1.11.0" CSV = "0.10.15" DataFrames = "1.7.0" -GeneralUtils = "0.6.6" +GeneralUtils = "0.6.9" HTTP = "2.4.0" JSON = "1.6.1" NATS = "0.1.0" diff --git a/my_AI_prompt.md b/my_AI_prompt.md index b9d0f8b..2ad6765 100644 --- a/my_AI_prompt.md +++ b/my_AI_prompt.md @@ -1 +1,94 @@ -read this code \ No newline at end of file +can you make sure yiemAgent use correct format when contacting MCP server. you can check MCP server doc at /home/ton/docker-apps/sommpanion/mcp/docs/specification.md? + +smartpack() and smartunpack() doc is at /home/ton/docker-apps/sommpanion/msghandler/README.md + +user code that use yiemAgent is here /home/ton/docker-apps/sommpanion/agent-backend/test/runtest.jl + +this is log folder that logs when I ran yiemAgent. you can check /home/ton/docker-apps/sommpanion/agent-backend/log/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/agentCore.jl b/src/agentCore.jl index b587e67..d49bb32 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -114,6 +114,27 @@ function yiemAgent( followUp = Channel(32) outputChannel = Channel(16) + if mcpServer === nothing + """ please implement the following MCP server interface + struct mcpServer + natsConn::NATS.Connection + topic::String + senderID::String + end + + # MCP protocol: tools/list — returns tool definitions (JSON-RPC 2.0) + # check input and output format from MCP server doc + function (mcp::mcpServer)(method::String, sessionId::String)::AbstractDict{String, <:Any} + error("please implement MCP server tools/list interface") + end + + # MCP protocol: tools/call — executes a tool (JSON-RPC 2.0) + function (mcp::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}, + sessionId::String)::AbstractDict{String, <:Any} + error("please implement MCP server interface") + end + """ + end tools = OrderedDict{String, agentTool}() registerAllTools(tools, mcpServer; eventSink=eventSink, sessionId=sessionId) @@ -211,18 +232,18 @@ function _agentLoop(agent::yiemAgent) while true while newUserMsg === nothing if isready(agent.inputChannel) - agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) - agent.eventSink("new user msg") + agent.eventSink("new user msg"; publish=false, log=true) else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # if agent runs is done but followUpChannel has messages, # put new message in inputChannel instead if isready(agent.followUpChannel) - agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) while isready(agent.followUpChannel) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) @@ -230,18 +251,18 @@ function _agentLoop(agent::yiemAgent) processingTask = nothing # reset result = nothing # reset else # _processMessage() done and no followUp message. - agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) result = deepcopy(agent._state.messages[end]) - agent.eventSink("_agentLoop 4-1 ") + agent.eventSink("_agentLoop 4-1 "; publish=false, log=true) # filter out reasoningContent in-place filter!(c -> !(c isa reasoningContent), result.content) - agent.eventSink("_agentLoop 5 ") + agent.eventSink("_agentLoop 5 "; publish=false, log=true) # format output respondToUI = _assistantMessageToOpenAI(result) - agent.eventSink("_agentLoop 6 ") + agent.eventSink("_agentLoop 6 "; publish=false, log=true) put!(agent.outputChannel, respondToUI) if !isempty(result.content) && result.content[1] isa textContent - agent.eventSink(result.content[1].text) + agent.eventSink(result.content[1].text; publish=false, log=true) end processingTask = nothing # reset result = nothing # reset @@ -272,7 +293,7 @@ function _agentLoop(agent::yiemAgent) else # spawn new _processMessage() if it is not already running. if processingTask === nothing - agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # discard all messages in followUpChannel while isready(agent.followUpChannel) _ = take!(agent.followUpChannel) @@ -303,7 +324,7 @@ function _agentLoop(agent::yiemAgent) showerror(io, e, bt) println(io) end - eventSink(errMsg) + eventSink(errMsg; publish=false, log=true) # On any error, send error response and exit the loop @error "Agent loop failed" error=e @@ -351,7 +372,7 @@ function _processMessage( afterToolCall::Union{Function, Nothing}, parallelToolExecute::Bool, )::Nothing - eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # loop until llmCall() response didn't use tool calls final_response = nothing @@ -372,43 +393,43 @@ function _processMessage( try # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) - eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) newUserMsg_openai = take!(inputChannel) - eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) if newUserMsg_openai === :shutdown - eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end - eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) newUserMsg = OpenAiToUserMessage(newUserMsg_openai) push!(agentMsgHistory, newUserMsg) - eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) end - eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # call prepareContext() state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) preparedContext = prepareContext(state, eventSink) - eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, eventSink) - eventSink("_processMessage 10 formattedMessages $formattedMessages") + eventSink("_processMessage 10 formattedMessages $formattedMessages"; publish=false, log=true) """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ response = llmCall(formattedMessages) - eventSink("LLM response " * string(response)) + eventSink("LLM response " * string(response); publish=false, log=true) - eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") - eventSink("assistant_msg " * string(assistant_msg)) - eventSink("_processMessage 11-1") + eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("assistant_msg " * string(assistant_msg); publish=false, log=true) + eventSink("_processMessage 11-1"; publish=false, log=true) # Add assistant message (tool calls or text) to history for next LLM turn push!(agentMsgHistory, assistant_msg) @@ -423,21 +444,21 @@ function _processMessage( ) signal = abortSignal(false) - eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # call executeToolCalls() toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, eventSink) - eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # save toolResults to messages for toolResult in toolResultBatch.messages - eventSink("toolResult " * string(toolResult)) + eventSink("toolResult " * string(toolResult); publish=false, log=true) push!(agentMsgHistory, toolResult) end - eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) if toolResultBatch.terminate - eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] for toolResult in toolResultBatch.messages @@ -451,7 +472,7 @@ function _processMessage( end end end - eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) final_response = assistantMessage( role="assistant", content=final_content, @@ -470,7 +491,7 @@ function _processMessage( break end else - eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # LLM did not use tool calls — break end @@ -481,12 +502,12 @@ function _processMessage( println(io) end - eventSink(err_msg) + eventSink(err_msg; publish=false, log=true) end end - eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) return nothing end @@ -926,46 +947,46 @@ function prepareToolCall( signal::abortSignal, eventSink )::Union{preparedToolCall,immediateOutcome} - eventSink("prepareToolCall 1") + eventSink("prepareToolCall 1"; publish=false, log=true) # pick a called tool from tool store tool = get(context.tools, toolCall.name, nothing) if tool === nothing - eventSink("prepareToolCall 2") + eventSink("prepareToolCall 2"; publish=false, log=true) return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end try - eventSink("prepareToolCall 3") + eventSink("prepareToolCall 3"; publish=false, log=true) # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) - eventSink("prepared " * string(prepared.arguments)) - eventSink("prepareToolCall 4") + eventSink("prepared " * string(prepared.arguments); publish=false, log=true) + eventSink("prepareToolCall 4"; publish=false, log=true) validatedArgs = validateToolArguments(tool, prepared) - eventSink("validatedArgs " * string(validatedArgs)) - eventSink("prepareToolCall 5") + eventSink("validatedArgs " * string(validatedArgs); publish=false, log=true) + eventSink("prepareToolCall 5"; publish=false, log=true) # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing - eventSink("prepareToolCall 6") + eventSink("prepareToolCall 6"; publish=false, log=true) before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) - eventSink("prepareToolCall 7") + eventSink("prepareToolCall 7"; publish=false, log=true) if signal.aborted - eventSink("prepareToolCall 8") + eventSink("prepareToolCall 8"; publish=false, log=true) return immediateOutcome(createErrorToolResult("Operation aborted"), true) end if before !== nothing && before.block - eventSink("prepareToolCall 9") + eventSink("prepareToolCall 9"; publish=false, log=true) return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end - eventSink("prepareToolCall 10") + eventSink("prepareToolCall 10"; publish=false, log=true) return preparedToolCall(tool, toolCall, validatedArgs) catch e bt = catch_backtrace() @@ -974,7 +995,7 @@ function prepareToolCall( println(io) end - eventSink(errMsg) + eventSink(errMsg; publish=false, log=true) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -1026,13 +1047,13 @@ function executePreparedToolCall( signal::Union{Nothing,abortSignal}, eventSink, )::executedOutcome - eventSink("executePreparedToolCall 1") + eventSink("executePreparedToolCall 1"; publish=false, log=true) try #WORKING pass sessionId from yiemAgent to tool call result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink) - eventSink("executePreparedToolCall 2") - eventSink("tool execute raw result: " * sprint(show, result)) - eventSink("executePreparedToolCall 3") + eventSink("executePreparedToolCall 2"; publish=false, log=true) + eventSink("tool execute raw result: " * sprint(show, result); publish=false, log=true) + eventSink("executePreparedToolCall 3"; publish=false, log=true) return executedOutcome(result, false) catch e bt = catch_backtrace() @@ -1040,7 +1061,7 @@ function executePreparedToolCall( showerror(io, e, bt) println(io) end - eventSink(errMsg) + eventSink(errMsg; publish=false, log=true) return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -1112,17 +1133,17 @@ function finalizeExecutedToolCall( signal::Union{Nothing,abortSignal}, eventSink )::finalizedOutcome - eventSink("finalizeExecutedToolCall 1") + eventSink("finalizeExecutedToolCall 1"; publish=false, log=true) result = executed.result isError = executed.isError - eventSink("finalizeExecutedToolCall 2") + eventSink("finalizeExecutedToolCall 2"; publish=false, log=true) if config.afterToolCall !== nothing try after = config.afterToolCall( afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) - eventSink("finalizeExecutedToolCall 3") + eventSink("finalizeExecutedToolCall 3"; publish=false, log=true) if after !== nothing result = merge(result, dict(:content=>get(after,:content,result.content), :details=>get(after,:details,result.details), @@ -1136,13 +1157,13 @@ function finalizeExecutedToolCall( showerror(io, e, bt) println(io) end - eventSink(errMsg) + eventSink(errMsg; publish=false, log=true) result = createErrorToolResult(sprint(showerror, e)) isError = true end end - eventSink("finalizeExecutedToolCall 4") + eventSink("finalizeExecutedToolCall 4"; publish=false, log=true) return finalizedOutcome(prep.toolCall, result, isError) end @@ -1208,39 +1229,39 @@ function executeToolCallsSequential( signal::abortSignal, eventSink, )::agentToolCallBatch - eventSink("executeToolCallsSequential 1") + eventSink("executeToolCallsSequential 1"; publish=false, log=true) finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] for tc in toolCalls - eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)"; publish=false, log=true) prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) - eventSink("executeToolCallsSequential " * string(prep.args)) + eventSink("executeToolCallsSequential " * string(prep.args); publish=false, log=true) if prep isa immediateOutcome - eventSink("executeToolCallsSequential 2-1") + eventSink("executeToolCallsSequential 2-1"; publish=false, log=true) finalized = finalizedOutcome(tc, prep.result, prep.isError) - eventSink("executeToolCallsSequential 2-2") + eventSink("executeToolCallsSequential 2-2"; publish=false, log=true) else - eventSink("executeToolCallsSequential 3") + eventSink("executeToolCallsSequential 3"; publish=false, log=true) executed = executePreparedToolCall(prep, signal, eventSink) - eventSink("executeToolCallsSequential 3-1") + eventSink("executeToolCallsSequential 3-1"; publish=false, log=true) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal, eventSink) - eventSink("executeToolCallsSequential 3-2") + eventSink("executeToolCallsSequential 3-2"; publish=false, log=true) end - eventSink("executeToolCallsSequential 4") + eventSink("executeToolCallsSequential 4"; publish=false, log=true) eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), - $(finalized.result), $(finalized.isError)") + $(finalized.result), $(finalized.isError)"; publish=false, log=true) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) - eventSink("executeToolCallsSequential 5") + eventSink("executeToolCallsSequential 5"; publish=false, log=true) if signal !== nothing && signal.aborted break end end - eventSink("executeToolCallsSequential 6") + eventSink("executeToolCallsSequential 6"; publish=false, log=true) return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end @@ -1313,7 +1334,7 @@ function executeToolCallsParallel( entries = Union{finalizedOutcome,Task}[] for tc in toolCalls - eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments); publish=false, log=true) prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) @@ -1412,7 +1433,7 @@ function executeToolCalls( eventSink, )::agentToolCallBatch - eventSink("_executeToolCalls 1") + eventSink("_executeToolCalls 1"; publish=false, log=true) hasSequential = false for tc in toolCalls t = get(context.tools, tc.name, nothing) @@ -1421,13 +1442,13 @@ function executeToolCalls( break end end - eventSink("_executeToolCalls 2") + eventSink("_executeToolCalls 2"; publish=false, log=true) if config.toolExecution == "sequential" || hasSequential - eventSink("_executeToolCalls 3") + eventSink("_executeToolCalls 3"; publish=false, log=true) return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, eventSink) else - eventSink("_executeToolCalls 4") + eventSink("_executeToolCalls 4"; publish=false, log=true) return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, eventSink) end @@ -1563,11 +1584,6 @@ end - - - - - diff --git a/src/api.jl b/src/api.jl index 2d06848..13d6f4e 100644 --- a/src/api.jl +++ b/src/api.jl @@ -39,7 +39,7 @@ function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) end function promptAgent(agent::yiemAgent, text::String) - put!(agent.inputChannel, Dict("role" => "user", "content" => text)) + put!(agent.inputChannel, Dict("role" => "user", "content" => [Dict("type" => "text", "text" => text)])) return agent end @@ -97,7 +97,7 @@ function followUp(agent::yiemAgent, msg::AbstractDict{String, Any}) end function followUp(agent::yiemAgent, text::String) - put!(agent.followUpChannel, Dict("role" => "user", "content" => text)) + put!(agent.followUpChannel, Dict("role" => "user", "content" => [Dict("type" => "text", "text" => text)])) return agent end diff --git a/src/utils.jl b/src/utils.jl index 9c4c52c..73349e0 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,7 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, - beforeToolCall, afterToolCall, eventSink + beforeToolCall, afterToolCall using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils @@ -310,7 +310,7 @@ Common use cases: - `agentToolResult(...)` to return a modified result (content, details, usage, terminate flag can all be overridden). -""" +""" #WORKING arrange result from searchWine tool function afterToolCall(context::afterToolCallContext, signal::abortSignal )::Union{agentToolResult, Nothing} @@ -319,108 +319,6 @@ function afterToolCall(context::afterToolCallContext, signal::abortSignal return nothing end - -""" -Create an event sink for logging messages and/or publishing to NATS. - -# Fields -- `natsConn`: NATS connection, or `nothing` for file-only sink -- `topic`: NATS topic to publish to, or `nothing` for file-only sink -- `service_name`: Optional prefix added to log entries -- `log_file`: Path to the log file -- `max_log_entries`: Maximum number of entries to keep in the log file - -# Callable interface -```julia - (es::eventSink)(msg::String; log=true, publish=true) -> Nothing -``` -Write a timestamped log entry to file, publish to NATS, or both. - -# Examples -```julia -using NATS - -conn = NATS.connect() - -# With NATS — logs to file and publishes to "agent.events" -es = eventSink(natsConn=conn, topic="agent.events", service_name="agent1") -es("agent started") # both file + NATS (default) -es("task done"; log=false) # NATS only -es("checkpoint"; publish=false) # file only - -# File only — no NATS connection -es2 = eventSink(log_file="./agent.log", service_name="worker") -es2("ready") # file only - -NATS.drain(conn) -``` -""" -struct eventSink - natsConn::Union{NATS.Connection, Nothing} - topic::Union{String, Nothing} - service_name::String - log_file::String - max_log_entries::Int -end - -eventSink(; natsConn=nothing, topic=nothing, service_name="", log_file="./log/eventSink.md", max_log_entries=100) = - eventSink(natsConn, topic, service_name, log_file, max_log_entries) - -""" - rotate!(es::eventSink) -> Nothing - -Rewrite the log file keeping only the last `max_log_entries` entries. -Call after writing when the entry count reaches the limit. -""" -function rotate!(es::eventSink) - content = read(es.log_file, String) - entries = split(content, r"- \[", keepempty=false) - open(es.log_file, "w") do f - write(f, "- [") - for e in entries[2:end] - write(f, "- [", e) - end - end -end - -""" - (es::eventSink)(msg::String; log=true, publish=true) -> Nothing - -Write a timestamped log entry to file, publish to NATS, or both. - -# Arguments -- `msg`: The message to log or publish. -- `log`: If `true`, append the entry to `es.log_file` (default `true`). -- `publish`: If `true`, publish to the NATS topic (default `true`; no-op if no NATS connection was provided). -""" -function (es::eventSink)(msg::String; log::Bool=true, publish::Bool=true) - timestamp = Dates.format(now(), "yyyy-mm-ddTHH:MM:SS.sss") - svc_prefix = isempty(es.service_name) ? "" : "[$(es.service_name)] " - entry = "- [$timestamp] $svc_prefix$msg\n" - if log - log_dir = dirname(es.log_file) - mkpath(log_dir) - open(es.log_file, "a") do f - write(f, entry) - end - # Rotate after write — keep only last max_log_entries entries - content = read(es.log_file, String) - entries = split(content, r"- \[", keepempty=false) - if length(entries) > es.max_log_entries - rotate!(es) - end - end - if publish && es.topic !== nothing && es.natsConn !== nothing - try - NATS.publish(es.natsConn, es.topic, entry) - catch e - @warn "eventSink NATS publish failed" exception=e - end - end -end - - - """ Convert a userMessage to OpenAI message format. """ -- 2.52.0 From 4ac5b97d5334e5f2d4387caaa31e9c8950078cfc Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 3 Sep 2026 09:49:52 +0700 Subject: [PATCH 104/112] update --- src/agentCore.jl | 38 +++++++++++++++++++++----------------- src/utils.jl | 12 ++++-------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index d49bb32..9c196fc 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -19,6 +19,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # user sends prompt message to agent. if agent is idle, it process user message right away. # if agent is running, it process user message after the current tool call finished. + # Expect each message in OpenAI chat format inputChannel::Channel # Buffers messages the user sends while the agent is busy. Processed after all inputChannel @@ -422,7 +423,7 @@ function _processMessage( response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ response = llmCall(formattedMessages) - eventSink("LLM response " * string(response); publish=false, log=true) + # eventSink("LLM response " * string(response); publish=false, log=true) eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # Extract tool calls from LLM response content blocks @@ -453,7 +454,7 @@ function _processMessage( # save toolResults to messages for toolResult in toolResultBatch.messages - eventSink("toolResult " * string(toolResult); publish=false, log=true) + # eventSink("toolResult " * string(toolResult); publish=false, log=true) push!(agentMsgHistory, toolResult) end eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) @@ -538,7 +539,7 @@ agentToolResult([textContent("text", "Tool not found")], Dict{Any,Any}()) ``` """ function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], dict{any,any}()) + return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) end """ @@ -830,10 +831,10 @@ stopping the agent when other tools in the batch did not intend to terminate. julia> shouldTerminate(finalizedOutcome[]) false -julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), false), false) for _ in 1:2]) +julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], Dict{Any,Any}(), Dict{Any,Any}(), false), false) for _ in 1:2]) false -julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), true), false) for _ in 1:2]) +julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], Dict{Any,Any}(), Dict{Any,Any}(), true), false) for _ in 1:2]) true ``` """ @@ -972,7 +973,8 @@ function prepareToolCall( before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), - signal + signal, + eventSink ) eventSink("prepareToolCall 7"; publish=false, log=true) if signal.aborted @@ -1034,7 +1036,7 @@ emitted after the result has already been captured. ```julia # Successful execution executePreparedToolCall(prep, nothing, emit) -# => executedOutcome(agentToolResult([textContent("text", "Found 3 wines")], dict{any,any}(), dict{any,any}()), false) +# => executedOutcome(agentToolResult([textContent("text", "Found 3 wines")], Dict{Any,Any}(), Dict{Any,Any}()), false) # Execution error executePreparedToolCall(prep, nothing, emit) @@ -1049,8 +1051,11 @@ function executePreparedToolCall( )::executedOutcome eventSink("executePreparedToolCall 1"; publish=false, log=true) - try #WORKING pass sessionId from yiemAgent to tool call + try + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink) + + eventSink("executePreparedToolCall 2"; publish=false, log=true) eventSink("tool execute raw result: " * sprint(show, result); publish=false, log=true) eventSink("executePreparedToolCall 3"; publish=false, log=true) @@ -1065,6 +1070,7 @@ function executePreparedToolCall( return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end + end @@ -1136,20 +1142,18 @@ function finalizeExecutedToolCall( eventSink("finalizeExecutedToolCall 1"; publish=false, log=true) result = executed.result isError = executed.isError - eventSink("finalizeExecutedToolCall 2"; publish=false, log=true) + eventSink("finalizeExecutedToolCall result before afterToolCall\n$result"; publish=false, log=true) if config.afterToolCall !== nothing try after = config.afterToolCall( afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), - signal + signal, + eventSink ) eventSink("finalizeExecutedToolCall 3"; publish=false, log=true) if after !== nothing - result = merge(result, dict(:content=>get(after,:content,result.content), - :details=>get(after,:details,result.details), - :usage=>get(after,:usage,result.usage), - :terminate=>get(after,:terminate,result.terminate))) - isError = get(after, :isError, isError) + result = after + isError = false end catch e bt = catch_backtrace() @@ -1252,8 +1256,8 @@ function executeToolCallsSequential( eventSink("executeToolCallsSequential 3-2"; publish=false, log=true) end eventSink("executeToolCallsSequential 4"; publish=false, log=true) - eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), - $(finalized.result), $(finalized.isError)"; publish=false, log=true) + # eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), + # $(finalized.result), $(finalized.isError)"; publish=false, log=true) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) eventSink("executeToolCallsSequential 5"; publish=false, log=true) diff --git a/src/utils.jl b/src/utils.jl index 73349e0..815f3a0 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -275,14 +275,10 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal) end ``` """ -function beforeToolCall(context::beforeToolCallContext, signal::abortSignal +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal, eventSink )::beforeToolCallResult - # final context check - - # seek user approval via UI - - # other check + eventSink("beforeToolCall: $(context.toolCall.name) args=$(context.args)") return beforeToolCallResult(false, "N/A") end @@ -311,10 +307,10 @@ Common use cases: terminate flag can all be overridden). """ #WORKING arrange result from searchWine tool -function afterToolCall(context::afterToolCallContext, signal::abortSignal +function afterToolCall(context::afterToolCallContext, signal::abortSignal, eventSink )::Union{agentToolResult, Nothing} - # modify context.result if needed and return agentToolResult + eventSink("afterToolCall: $(context.toolCall.name) result=$(context.result) error=$(context.isError)") return nothing end -- 2.52.0 From f6895984d6d38958c476940a64f948df266cd52b Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 3 Sep 2026 11:33:17 +0700 Subject: [PATCH 105/112] update --- src/agentCore.jl | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 9c196fc..1853b9d 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -19,15 +19,32 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # user sends prompt message to agent. if agent is idle, it process user message right away. # if agent is running, it process user message after the current tool call finished. - # Expect each message in OpenAI chat format + # Expect each message in OpenAI chat format: + # Dict("role" => "user", "content" => [ + # Dict("type" => "text", "text" => "Your question here"), + # Dict("type" => "image_url", "image_url" => Dict("url" => "data:image/png;base64,")) + # ]) + # To embed an image, read it as bytes and encode to base64: + # img = read("path/to/image.png") + # b64 = base64encode(img) + # data_uri = "data:image/png;base64,$b64" + # message = Dict("role" => "user", "content" => [ + # Dict("type" => "text", "text" => "Do you have this wine?"), + # Dict("type" => "image_url", "image_url" => Dict("url" => data_uri)) + # ]) + # Multiple images can be included alongside text in the content array. inputChannel::Channel - # Buffers messages the user sends while the agent is busy. Processed after all inputChannel - # messages are handled and the agent is idle (not using a tool call). + # Buffers follow-up messages the user sends while the agent is busy. Uses the same + # OpenAI chat format as inputChannel. Processed after all inputChannel messages are + # handled and the agent is idle (not in the middle of a tool call). followUpChannel::Channel - # agent sends response message to user after processing all user messages in inputChannel - # and all followUp messages. + # agent sends response message to user after processing all user messages in inputChannel + # and all followUp messages. Response is in OpenAI message format: + # Dict("role" => "assistant", "content" => [ + # Dict("type" => "text", "text" => "Response text here") + # ]) outputChannel::Channel _agentLoop::Union{Task, Nothing} # agent loop running in the background @@ -116,7 +133,7 @@ function yiemAgent( outputChannel = Channel(16) if mcpServer === nothing - """ please implement the following MCP server interface + error(""" please implement the following MCP server interface struct mcpServer natsConn::NATS.Connection topic::String @@ -134,7 +151,7 @@ function yiemAgent( sessionId::String)::AbstractDict{String, <:Any} error("please implement MCP server interface") end - """ + """) end tools = OrderedDict{String, agentTool}() -- 2.52.0 From c355748e6f91effd997769bbb2b2748ffaa612ed Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 3 Sep 2026 11:38:29 +0700 Subject: [PATCH 106/112] update --- src/agentCore.jl | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 1853b9d..16491cf 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -20,19 +20,33 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # user sends prompt message to agent. if agent is idle, it process user message right away. # if agent is running, it process user message after the current tool call finished. # Expect each message in OpenAI chat format: - # Dict("role" => "user", "content" => [ - # Dict("type" => "text", "text" => "Your question here"), - # Dict("type" => "image_url", "image_url" => Dict("url" => "data:image/png;base64,")) - # ]) - # To embed an image, read it as bytes and encode to base64: - # img = read("path/to/image.png") - # b64 = base64encode(img) - # data_uri = "data:image/png;base64,$b64" - # message = Dict("role" => "user", "content" => [ - # Dict("type" => "text", "text" => "Do you have this wine?"), - # Dict("type" => "image_url", "image_url" => Dict("url" => data_uri)) - # ]) - # Multiple images can be included alongside text in the content array. + # image1_path = "test/large_image.png" + # image1_bytes = read(image1_path) + # image1_base64_string = base64encode(image1_bytes) + # mime_type = "image/png" + # data1_uri = "data:$(mime_type);base64,$(image1_base64_string)" + + # # 1. Read local file and encode to base64 string + # image2_path = "test/small_image.png" + # image2_bytes = read(image2_path) + # image2_base64_string = base64encode(image2_bytes) + # mime_type = "image/png" + # data2_uri = "data:$(mime_type);base64,$(image2_base64_string)" + + # # 3. Construct payload with the Data URI + # message = Dict( + # "role" => "user", + # "content" => [ + # Dict("type" => "text", "text" => + # " + # Do you have Brunello di Montalcino from Tenuta CastelGiocon? + # "), + # Dict( + # "type" => "image_url", + # "image_url" => Dict("url" => data1_uri) + # ) + # ] + # ) inputChannel::Channel # Buffers follow-up messages the user sends while the agent is busy. Uses the same -- 2.52.0 From 471da0c5ecab4022b28e9b1a5d3751a84e899958 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 3 Sep 2026 20:12:37 +0700 Subject: [PATCH 107/112] pass eventSink to MCP --- src/agentCore.jl | 6 +++--- src/toolRegistry.jl | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 16491cf..c27fb5b 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -156,13 +156,13 @@ function yiemAgent( # MCP protocol: tools/list — returns tool definitions (JSON-RPC 2.0) # check input and output format from MCP server doc - function (mcp::mcpServer)(method::String, sessionId::String)::AbstractDict{String, <:Any} + function (mcp::mcpServer)(method::String, sessionId::String; eventSink=nothing)::AbstractDict{String, <:Any} error("please implement MCP server tools/list interface") end # MCP protocol: tools/call — executes a tool (JSON-RPC 2.0) function (mcp::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}, - sessionId::String)::AbstractDict{String, <:Any} + sessionId::String; eventSink=nothing)::AbstractDict{String, <:Any} error("please implement MCP server interface") end """) @@ -453,7 +453,7 @@ function _processMessage( """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ - response = llmCall(formattedMessages) + response = llmCall(formattedMessages, eventSink=eventSink) # eventSink("LLM response " * string(response); publish=false, log=true) eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 9fbe6bc..cceb5c1 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -68,7 +68,7 @@ function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSin signal::Union{Nothing,abortSignal}, eventSink) -> begin try - response = mcpserver("tools/call", name, args, sessionId) + response = mcpserver("tools/call", name, args, sessionId; eventSink=eventSink) # Parse JSON-RPC 2.0 response envelope if haskey(response, "error") @@ -122,7 +122,7 @@ function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; ev new_count = 0 try eventSink("register_mcp_tools 1") - response = mcpserver("tools/list", sessionId) + response = mcpserver("tools/list", sessionId; eventSink=eventSink) eventSink("register_mcp_tools 2") # Parse JSON-RPC 2.0 response envelope if haskey(response, "error") @@ -151,7 +151,7 @@ function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; ev # Paginate: fetch remaining tools if nextCursor is present while cursor !== nothing && cursor !== "" - response = sessionId !== nothing ? mcpserver("tools/list", sessionId) : mcpserver("tools/list") + response = sessionId !== nothing ? mcpserver("tools/list", sessionId; eventSink=eventSink) : mcpserver("tools/list"; eventSink=eventSink) if haskey(response, "result") response = response["result"] end -- 2.52.0 From 1f3526b9969351683af7e53d8a52e3b40381d7ba Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 3 Sep 2026 22:06:28 +0700 Subject: [PATCH 108/112] update --- src/agentCore.jl | 6 +++--- src/api.jl | 2 +- src/utils.jl | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index c27fb5b..57c00e1 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -267,7 +267,7 @@ function _agentLoop(agent::yiemAgent) agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) - agent.eventSink("new user msg"; publish=false, log=true) + agent.eventSink("new user msg\n$newUserMsg"; publish=false, log=true) else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true @@ -1524,13 +1524,13 @@ OpenAiToUserMessage(msg) # => userMessage("user", [textContent("Hello")], DateTime(...)) ``` """ -function OpenAiToUserMessage(msg::Dict)::userMessage +function OpenAiToUserMessage(msg::AbstractDict)::userMessage content_blocks = Vector{messageContent}() raw_content = get(msg, "content", Any[]) if raw_content isa Vector for block in raw_content - if block isa Dict + if typeof(block) <: AbstractDict block_type = get(block, "type", "") if block_type == "text" text = get(block, "text", "") diff --git a/src/api.jl b/src/api.jl index 13d6f4e..da0bc94 100644 --- a/src/api.jl +++ b/src/api.jl @@ -33,7 +33,7 @@ julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!")) yiemAgent(...) ``` """ -function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) +function promptAgent(agent::yiemAgent, msg::Dict{String, Any}) put!(agent.inputChannel, msg) return agent end diff --git a/src/utils.jl b/src/utils.jl index 815f3a0..1a4c565 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -2,8 +2,8 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, - beforeToolCall, afterToolCall + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, + _toolsToOpenAI, beforeToolCall, afterToolCall using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils -- 2.52.0 From b3ae0a9c50905a2016afe9ded9da8a00e54e3fa2 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 3 Sep 2026 22:09:22 +0700 Subject: [PATCH 109/112] update --- src/api.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.jl b/src/api.jl index da0bc94..13d6f4e 100644 --- a/src/api.jl +++ b/src/api.jl @@ -33,7 +33,7 @@ julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!")) yiemAgent(...) ``` """ -function promptAgent(agent::yiemAgent, msg::Dict{String, Any}) +function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) put!(agent.inputChannel, msg) return agent end -- 2.52.0 From 390b9d71b64be121669a3cbf9658cbc33f82ed0d Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 8 Sep 2026 06:04:02 +0700 Subject: [PATCH 110/112] update --- src/agentCore.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 57c00e1..876051a 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -267,7 +267,7 @@ function _agentLoop(agent::yiemAgent) agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) - agent.eventSink("new user msg\n$newUserMsg"; publish=false, log=true) + # agent.eventSink("new user msg\n$newUserMsg"; publish=false, log=true) else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true @@ -448,7 +448,7 @@ function _processMessage( # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, eventSink) - eventSink("_processMessage 10 formattedMessages $formattedMessages"; publish=false, log=true) + # eventSink("_processMessage 10 formattedMessages $formattedMessages"; publish=false, log=true) """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) -- 2.52.0 From 9b593675c7d1a228d4d96623732f9c1013d2a376 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 11 Sep 2026 07:55:32 +0700 Subject: [PATCH 111/112] update --- src/agentCore.jl | 151 +++++++++++++++++++++++------------------------ 1 file changed, 73 insertions(+), 78 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 876051a..57524ea 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -264,18 +264,18 @@ function _agentLoop(agent::yiemAgent) while true while newUserMsg === nothing if isready(agent.inputChannel) - agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) + agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))"; publish=true) # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) - # agent.eventSink("new user msg\n$newUserMsg"; publish=false, log=true) + # agent.eventSink("new user msg\n$newUserMsg"; publish=true) else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) + agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))"; publish=true) # if agent runs is done but followUpChannel has messages, # put new message in inputChannel instead if isready(agent.followUpChannel) - agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) + agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))"; publish=true) while isready(agent.followUpChannel) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) @@ -283,18 +283,18 @@ function _agentLoop(agent::yiemAgent) processingTask = nothing # reset result = nothing # reset else # _processMessage() done and no followUp message. - agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) + agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))"; publish=true) result = deepcopy(agent._state.messages[end]) - agent.eventSink("_agentLoop 4-1 "; publish=false, log=true) + agent.eventSink("_agentLoop 4-1 "; publish=true) # filter out reasoningContent in-place filter!(c -> !(c isa reasoningContent), result.content) - agent.eventSink("_agentLoop 5 "; publish=false, log=true) + agent.eventSink("_agentLoop 5 "; publish=true) # format output respondToUI = _assistantMessageToOpenAI(result) - agent.eventSink("_agentLoop 6 "; publish=false, log=true) + agent.eventSink("_agentLoop 6 "; publish=true) put!(agent.outputChannel, respondToUI) if !isempty(result.content) && result.content[1] isa textContent - agent.eventSink(result.content[1].text; publish=false, log=true) + agent.eventSink(result.content[1].text; publish=true) end processingTask = nothing # reset result = nothing # reset @@ -325,7 +325,7 @@ function _agentLoop(agent::yiemAgent) else # spawn new _processMessage() if it is not already running. if processingTask === nothing - agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) + agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))"; publish=true) # discard all messages in followUpChannel while isready(agent.followUpChannel) _ = take!(agent.followUpChannel) @@ -356,7 +356,7 @@ function _agentLoop(agent::yiemAgent) showerror(io, e, bt) println(io) end - eventSink(errMsg; publish=false, log=true) + eventSink(errMsg; publish=true) # On any error, send error response and exit the loop @error "Agent loop failed" error=e @@ -404,7 +404,7 @@ function _processMessage( afterToolCall::Union{Function, Nothing}, parallelToolExecute::Bool, )::Nothing - eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))"; publish=true) # loop until llmCall() response didn't use tool calls final_response = nothing @@ -425,43 +425,43 @@ function _processMessage( try # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) - eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))"; publish=true) newUserMsg_openai = take!(inputChannel) - eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))"; publish=true) if newUserMsg_openai === :shutdown - eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))"; publish=true) # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end - eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))"; publish=true) newUserMsg = OpenAiToUserMessage(newUserMsg_openai) push!(agentMsgHistory, newUserMsg) - eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))"; publish=true) end - eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))"; publish=true) # call prepareContext() state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))"; publish=true) preparedContext = prepareContext(state, eventSink) - eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))"; publish=true) # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, eventSink) - # eventSink("_processMessage 10 formattedMessages $formattedMessages"; publish=false, log=true) + # eventSink("_processMessage 10 formattedMessages $formattedMessages"; publish=true) """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ response = llmCall(formattedMessages, eventSink=eventSink) - # eventSink("LLM response " * string(response); publish=false, log=true) + # eventSink("LLM response " * string(response); publish=true) - eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))"; publish=true) # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) - eventSink("assistant_msg " * string(assistant_msg); publish=false, log=true) - eventSink("_processMessage 11-1"; publish=false, log=true) + eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))"; publish=true) + eventSink("assistant_msg " * string(assistant_msg); publish=true) + eventSink("_processMessage 11-1"; publish=true) # Add assistant message (tool calls or text) to history for next LLM turn push!(agentMsgHistory, assistant_msg) @@ -476,21 +476,21 @@ function _processMessage( ) signal = abortSignal(false) - eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))"; publish=true) # call executeToolCalls() toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, eventSink) - eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))"; publish=true) # save toolResults to messages for toolResult in toolResultBatch.messages - # eventSink("toolResult " * string(toolResult); publish=false, log=true) + # eventSink("toolResult " * string(toolResult); publish=true) push!(agentMsgHistory, toolResult) end - eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))"; publish=true) if toolResultBatch.terminate - eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))"; publish=true) # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] for toolResult in toolResultBatch.messages @@ -504,7 +504,7 @@ function _processMessage( end end end - eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))"; publish=true) final_response = assistantMessage( role="assistant", content=final_content, @@ -523,7 +523,7 @@ function _processMessage( break end else - eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))"; publish=true) # LLM did not use tool calls — break end @@ -534,12 +534,12 @@ function _processMessage( println(io) end - eventSink(err_msg; publish=false, log=true) + eventSink(err_msg; publish=true) end end - eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))"; publish=true) return nothing end @@ -979,47 +979,47 @@ function prepareToolCall( signal::abortSignal, eventSink )::Union{preparedToolCall,immediateOutcome} - eventSink("prepareToolCall 1"; publish=false, log=true) + eventSink("prepareToolCall 1"; publish=true) # pick a called tool from tool store tool = get(context.tools, toolCall.name, nothing) if tool === nothing - eventSink("prepareToolCall 2"; publish=false, log=true) + eventSink("prepareToolCall 2"; publish=true) return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end try - eventSink("prepareToolCall 3"; publish=false, log=true) + eventSink("prepareToolCall 3"; publish=true) # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) - eventSink("prepared " * string(prepared.arguments); publish=false, log=true) - eventSink("prepareToolCall 4"; publish=false, log=true) + eventSink("prepared " * string(prepared.arguments); publish=true) + eventSink("prepareToolCall 4"; publish=true) validatedArgs = validateToolArguments(tool, prepared) - eventSink("validatedArgs " * string(validatedArgs); publish=false, log=true) - eventSink("prepareToolCall 5"; publish=false, log=true) + eventSink("validatedArgs " * string(validatedArgs); publish=true) + eventSink("prepareToolCall 5"; publish=true) # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing - eventSink("prepareToolCall 6"; publish=false, log=true) + eventSink("prepareToolCall 6"; publish=true) before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal, eventSink ) - eventSink("prepareToolCall 7"; publish=false, log=true) + eventSink("prepareToolCall 7"; publish=true) if signal.aborted - eventSink("prepareToolCall 8"; publish=false, log=true) + eventSink("prepareToolCall 8"; publish=true) return immediateOutcome(createErrorToolResult("Operation aborted"), true) end if before !== nothing && before.block - eventSink("prepareToolCall 9"; publish=false, log=true) + eventSink("prepareToolCall 9"; publish=true) return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end - eventSink("prepareToolCall 10"; publish=false, log=true) + eventSink("prepareToolCall 10"; publish=true) return preparedToolCall(tool, toolCall, validatedArgs) catch e bt = catch_backtrace() @@ -1028,7 +1028,7 @@ function prepareToolCall( println(io) end - eventSink(errMsg; publish=false, log=true) + eventSink(errMsg; publish=true) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -1080,16 +1080,11 @@ function executePreparedToolCall( signal::Union{Nothing,abortSignal}, eventSink, )::executedOutcome - eventSink("executePreparedToolCall 1"; publish=false, log=true) + eventSink("executePreparedToolCall 1"; publish=true) try - result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink) - - - eventSink("executePreparedToolCall 2"; publish=false, log=true) - eventSink("tool execute raw result: " * sprint(show, result); publish=false, log=true) - eventSink("executePreparedToolCall 3"; publish=false, log=true) + # eventSink("tool execute raw result: " * sprint(show, result); publish=true) return executedOutcome(result, false) catch e bt = catch_backtrace() @@ -1097,7 +1092,7 @@ function executePreparedToolCall( showerror(io, e, bt) println(io) end - eventSink(errMsg; publish=false, log=true) + eventSink(errMsg; publish=true) return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -1170,10 +1165,10 @@ function finalizeExecutedToolCall( signal::Union{Nothing,abortSignal}, eventSink )::finalizedOutcome - eventSink("finalizeExecutedToolCall 1"; publish=false, log=true) + eventSink("finalizeExecutedToolCall 1"; publish=true) result = executed.result isError = executed.isError - eventSink("finalizeExecutedToolCall result before afterToolCall\n$result"; publish=false, log=true) + # eventSink("finalizeExecutedToolCall result before afterToolCall\n$result"; publish=true) if config.afterToolCall !== nothing try after = config.afterToolCall( @@ -1181,7 +1176,7 @@ function finalizeExecutedToolCall( signal, eventSink ) - eventSink("finalizeExecutedToolCall 3"; publish=false, log=true) + eventSink("finalizeExecutedToolCall 3"; publish=true) if after !== nothing result = after isError = false @@ -1192,13 +1187,13 @@ function finalizeExecutedToolCall( showerror(io, e, bt) println(io) end - eventSink(errMsg; publish=false, log=true) + eventSink(errMsg; publish=true) result = createErrorToolResult(sprint(showerror, e)) isError = true end end - eventSink("finalizeExecutedToolCall 4"; publish=false, log=true) + eventSink("finalizeExecutedToolCall 4"; publish=true) return finalizedOutcome(prep.toolCall, result, isError) end @@ -1264,39 +1259,39 @@ function executeToolCallsSequential( signal::abortSignal, eventSink, )::agentToolCallBatch - eventSink("executeToolCallsSequential 1"; publish=false, log=true) + eventSink("executeToolCallsSequential 1"; publish=true) finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] for tc in toolCalls - eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)"; publish=false, log=true) + eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)"; publish=true) prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) - eventSink("executeToolCallsSequential " * string(prep.args); publish=false, log=true) + eventSink("executeToolCallsSequential " * string(prep.args); publish=true) if prep isa immediateOutcome - eventSink("executeToolCallsSequential 2-1"; publish=false, log=true) + eventSink("executeToolCallsSequential 2-1"; publish=true) finalized = finalizedOutcome(tc, prep.result, prep.isError) - eventSink("executeToolCallsSequential 2-2"; publish=false, log=true) + eventSink("executeToolCallsSequential 2-2"; publish=true) else - eventSink("executeToolCallsSequential 3"; publish=false, log=true) + eventSink("executeToolCallsSequential 3"; publish=true) executed = executePreparedToolCall(prep, signal, eventSink) - eventSink("executeToolCallsSequential 3-1"; publish=false, log=true) + eventSink("executeToolCallsSequential 3-1"; publish=true) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal, eventSink) - eventSink("executeToolCallsSequential 3-2"; publish=false, log=true) + eventSink("executeToolCallsSequential 3-2"; publish=true) end - eventSink("executeToolCallsSequential 4"; publish=false, log=true) + eventSink("executeToolCallsSequential 4"; publish=true) # eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), - # $(finalized.result), $(finalized.isError)"; publish=false, log=true) + # $(finalized.result), $(finalized.isError)"; publish=true) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) - eventSink("executeToolCallsSequential 5"; publish=false, log=true) + eventSink("executeToolCallsSequential 5"; publish=true) if signal !== nothing && signal.aborted break end end - eventSink("executeToolCallsSequential 6"; publish=false, log=true) + eventSink("executeToolCallsSequential 6"; publish=true) return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end @@ -1369,7 +1364,7 @@ function executeToolCallsParallel( entries = Union{finalizedOutcome,Task}[] for tc in toolCalls - eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments); publish=false, log=true) + eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments); publish=true) prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) @@ -1468,7 +1463,7 @@ function executeToolCalls( eventSink, )::agentToolCallBatch - eventSink("_executeToolCalls 1"; publish=false, log=true) + eventSink("_executeToolCalls 1"; publish=true) hasSequential = false for tc in toolCalls t = get(context.tools, tc.name, nothing) @@ -1477,13 +1472,13 @@ function executeToolCalls( break end end - eventSink("_executeToolCalls 2"; publish=false, log=true) + eventSink("_executeToolCalls 2"; publish=true) if config.toolExecution == "sequential" || hasSequential - eventSink("_executeToolCalls 3"; publish=false, log=true) + eventSink("_executeToolCalls 3"; publish=true) return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, eventSink) else - eventSink("_executeToolCalls 4"; publish=false, log=true) + eventSink("_executeToolCalls 4"; publish=true) return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, eventSink) end -- 2.52.0 From e561b4b1aec0ac876e4f1660ebd5a750b5c54db9 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 11 Sep 2026 08:28:49 +0700 Subject: [PATCH 112/112] update docs --- docs/1-requirements.md | 155 ++++++++++ docs/2-solution_design.md | 119 ++++++++ docs/3-walkthrough.md | 318 +++++++++++++++++++++ docs/4-specification.md | 518 ++++++++++++++++++++++++++++++++++ docs/6-validation.md | 192 +++++++++++++ docs/7-implementation-plan.md | 217 ++++++++++++++ docs/8-runbook.md | 445 +++++++++++++++++++++++++++++ 7 files changed, 1964 insertions(+) create mode 100644 docs/1-requirements.md create mode 100644 docs/2-solution_design.md create mode 100644 docs/3-walkthrough.md create mode 100644 docs/4-specification.md create mode 100644 docs/6-validation.md create mode 100644 docs/7-implementation-plan.md create mode 100644 docs/8-runbook.md diff --git a/docs/1-requirements.md b/docs/1-requirements.md new file mode 100644 index 0000000..4738814 --- /dev/null +++ b/docs/1-requirements.md @@ -0,0 +1,155 @@ +# YiemAgent: Agile Agent Framework + +## 1. Business Context & Success Metrics + +### Business Goal + +YiemAgent is a Julia-based AI agent framework that enables building chatbot assistants with tool-calling capabilities, model-agnostic LLM integration, and runtime tool discovery via MCP (Model Context Protocol). It provides an asynchronous agent loop with channel-based message passing, supporting both single-turn and multi-turn conversations with automatic tool call resolution. + +### User Stories + +- **US-001**: As a developer, I want to create an AI agent with a simple constructor so that I can quickly prototype chatbot functionality in Julia +- **US-002**: As a developer, I want the agent to discover tools automatically from an MCP server so that I don't need to manually register tool APIs +- **US-003**: As a developer, I want the agent to handle tool calls automatically in a loop so that the LLM can use tools without external orchestration +- **US-004**: As a developer, I want to intercept tool calls with before/after hooks so that I can add user approval, logging, or result sanitization +- **US-005**: As a developer, I want the framework to support multiple LLM models and providers through a pluggable `llmCall` interface so that I can swap models without changing agent logic + +### KPIs & Targets + +- **KPI-001**: Agent loop processes messages with <500ms overhead for sequential tool calls (validates US-003) +- **KPI-002**: MCP tool discovery completes within 2 seconds for 50+ tools (validates US-002) +- **KPI-003**: 100% of message types have OpenAI format conversion (validates US-001) + +## 2. Technical Boundaries + +### In Scope + +- Julia module implementing an AI agent with asynchronous message processing +- Channel-based input (user→agent), follow-up (async queue), and output (agent→user) interfaces +- LLM model abstraction with tool call extraction from multiple response formats (OpenAI content blocks, LMStudio/vLLM format) +- Tool registry with MCP server integration (tools/list, tools/call via JSON-RPC 2.0) +- Agent loop with before/after tool call hooks for lifecycle management +- Message type system with OpenAI format conversion utilities +- Abort signal and termination flag support via tool implementations + +### Out of Scope + +- GUI/CLI interface (exposed via channels only; UI layer is external) +- Persistent chat history or long-term memory (TODO comments in code) +- Multi-agent orchestration (single agent instance per `yiemAgent` struct) +- Embedding generation or retrieval-augmented generation (RAG) +- Natural language-to-code generation or execution sandboxes + +### Dependencies + +- **Julia Standard Library**: `Dates`, `UUIDs`, `Base.Threads` +- **External Packages**: `JSON`, `DataStructures`, `HTTP`, `PrettyPrinting`, `Serialization`, `DataFrames` +- **Internal Package**: `GeneralUtils` (project-specific utility library) +- **Optional**: `NATS` (MCP server communication), `LibPQ` (PostgreSQL) + +### Deployment Constraints + +- **NFR-001**: Library designed for embedded use in Docker containers (MicroK8s cluster, per `NFR-501` from template) +- **NFR-002**: Must support Julia 1.9+ for module include/export semantics compatibility +- **NFR-003**: Thread-safe channel operations; single-agent instance processes messages sequentially on background `@spawn` task + +## 3. Functional Requirements (FR) + +### FR-001: Agent Message Processing + +The agent shall process user messages through an asynchronous loop that: +- Accepts messages via `inputChannel` (capacity 16) and `followUpChannel` (capacity 32) +- Prioritizes `inputChannel` messages over `followUpChannel` messages +- Feeds responses to `outputChannel` (capacity 16) in OpenAI message format +- Exits cleanly on `:shutdown` signal + +**Traceability**: US-001, US-003 + +### FR-002: LLM Model Agnosticism + +The agent shall accept a pluggable `llmCall` function that: +- Receives formatted messages and returns an LLM response dict +- The response dict may contain `message.tool_calls` (LMStudio format) or `content` blocks (OpenAI format) +- The framework extracts tool calls regardless of response format + +**Traceability**: US-005 + +### FR-003: Tool Call Execution Loop + +The agent shall automatically execute tool calls returned by the LLM: +- Extract tool calls via `_extractToolCalls()` which supports both `message.tool_calls` and `content[{type="tool_calls"}]` formats +- Build `agentContext` via `prepareContext()` (pluggable hook) +- Format messages via `formatMsgForLLM()` to OpenAI format +- Extract tool calls → loop: execute tool calls → append results to history → call LLM again +- Break loop when LLM response has no tool calls +- Support `terminate` flag: if `toolResultBatch.terminate == true`, break loop and return final response + +**Traceability**: US-003 + +### FR-004: Tool Discovery and Registration + +The agent shall: +- Query MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0) to discover tools +- Support pagination via `nextCursor` field +- Wrap each MCP tool definition as an `agentTool` with `execute` hook that calls `mcpserver("tools/call", name, args, sessionId)` +- Register a builtin `listTools` tool that triggers runtime discovery and lists all available tools +- Skip tools already registered (idempotent) + +**Traceability**: US-002 + +### FR-005: Tool Call Lifecycle Hooks + +The agent shall support two lifecycle hooks: +- `beforeToolCall(beforeToolCallContext, signal)` — can block execution by returning `beforeToolCallResult(true, reason)` +- `afterToolCall(afterToolCallContext)` — can transform result by returning modified `agentToolResult` +- Errors from hooks are caught and converted to `immediateOutcome`/error tool result messages (never throw) + +**Traceability**: US-004 + +### FR-006: Message Type Conversion + +The agent shall: +- Convert internal message types (`userMessage`, `assistantMessage`, `assistantMessageToolCall`, `toolResultMessage`) to OpenAI format via `_userMessageToOpenAI()`, `_assistantMessageToOpenAI()`, `_assistantMessageToolCallToOpenAI()`, `_toolResultMessageToOpenAI()` +- Convert internal `messageContent` types (`textContent`, `imageContent`, `reasoningContent`) to OpenAI content blocks via `_messageContentToBlocks()` +- Convert `agentTool` definitions to OpenAI function tool format via `_toolsToOpenAI()` + +**Traceability**: US-001 + +## 4. Non-Functional Requirements (NFRs) + +### 4.1 Performance & Scalability + +- **NFR-101**: Agent loop message processing overhead <500ms per sequential tool call (measured KPI: KPI-001) +- **NFR-102**: MCP tool discovery for 50+ tools completes within 2 seconds (measured KPI: KPI-002) +- **NFR-103**: Channel buffers prevent unbounded memory growth — `inputChannel` (16), `followUpChannel` (32), `outputChannel` (16) + +### 4.2 Availability & Reliability + +- **NFR-201**: Agent loop catches all errors via `try/catch` in `_agentLoop()` and calls `eventSink(errMsg)` before exit (measured by event sink logging) +- **NFR-202**: `prepareToolCall()` never throws errors — validation failures return `immediateOutcome` with error tool result + +### 4.3 Privacy & Security + +- **Data Classification**: Tool arguments and results may contain PII (user messages); MCP tool results treated as untrusted input +- **No built-in auth/authz**: Responsibility of `mcpServer` implementation and `beforeToolCall` hook +- **Input validation**: JSON schema validation via `validateToolArguments()` checks required fields only (tools need custom validation via `validateRequiredArgs` hook) + +### 4.4 Observability & Telemetry + +- **Required Logs**: `agentLoop` events (via `eventSink`) emit state machine steps (`_agentLoop 1` through `_agentLoop 6`), `_processMessage` steps (1 through 18), `prepareToolCall` steps (1 through 10) +- **Critical Metrics**: + - `agent._state.messages.length` — conversation history size (logged at each step) + - `toolExecutionStart`/`toolExecutionEnd` events (via `toolExecStartEvent`, `toolExecEndEvent` structs) +- **Retention**: No built-in log retention; agent is a library, does not manage external logging + +## 5. Acceptance Conditions + +- [ ] **FR-001**: Agent processes messages through channel system with priority ordering (inputChannel > followUpChannel) +- [ ] **FR-002**: `llmCall` is pluggable; response formats supported include `message.tool_calls` dict and `content` array blocks +- [ ] **FR-003**: Agent loops on tool calls until LLM returns no tool calls or terminate flag is set +- [ ] **FR-004**: MCP tools are discovered via `tools/list` with pagination; all registered tools are callable via `tools/call` +- [ ] **FR-005**: `beforeToolCall` hook can block; `afterToolCall` hook can transform result; errors return error tool result +- [ ] **FR-006**: All message types (userMessage, assistantMessage, assistantMessageToolCall, toolResultMessage) convert to OpenAI format +- [ ] **NFR-101**: Message processing overhead <500ms per sequential tool call +- [ ] **NFR-102**: MCP discovery for 50+ tools completes <2 seconds +- [ ] **NFR-201**: Agent loop catches errors and exits gracefully without panics diff --git a/docs/2-solution_design.md b/docs/2-solution_design.md new file mode 100644 index 0000000..047a1b5 --- /dev/null +++ b/docs/2-solution_design.md @@ -0,0 +1,119 @@ +# Solution Design: YiemAgent Agent Framework + +## 1. Problem Decomposition + +| Problem | Description | User Impact | +|---------|-------------|-------------| +| P-001 | Developers need a chatbot framework in Julia that handles LLM calls and tool calls without writing boilerplate | Must write separate request/response handling, tool call parsing, and loop management code for each agent | +| P-002 | Tool discovery is manual and repetitive | Must register every tool individually; no automatic discovery from MCP-compatible servers | +| P-003 | Multiple LLM providers have different response formats | Must handle `message.tool_calls` (LMStudio/vLLM) vs `content` blocks (OpenAI API) separately | +| P-004 | Tool execution needs lifecycle hooks for observability and control | No built-in before/after hooks for authorization, logging, or result sanitization | +| P-005 | Agent state management is error-prone | Must handle message history, tool registration, and pending tool call tracking manually | + +## 2. Solution Approach + +**Approach**: "Build a channel-based agent system where the LLM talks to tools automatically, tools talk to MCP servers automatically, and you compose agents by injecting `llmCall` and `mcpServer` functions — the framework owns the loop, the state, and the format conversion." + +**Key Principles**: +- **Channel-based async processing**: Each agent owns three channels (`inputChannel`, `followUpChannel`, `outputChannel`); a background `@spawn` task runs the agent loop, processing messages in priority order +- **Tool call loop**: The agent calls `llmCall()`, extracts tool calls, executes them (sequentially or in parallel), appends results, and calls `llmCall()` again — repeating until no tool calls remain +- **Pluggable hooks**: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, and `llmCall` are all injectable functions — the framework provides sensible defaults, but you override what you need +- **MCP-native tool discovery**: Tools are discovered at startup via MCP `tools/list` and can be discovered at runtime via the `listTools` builtin tool (idempotent registration with pagination support) +- **Format-agnostic LLM responses**: `_extractToolCalls()` handles both OpenAI content blocks and LMStudio/vLLM nested message format, allowing the same agent to work with any model provider + +## 3. Decision Rationale + +| Decision ID | Decision | Rationale | Alternatives Rejected | +|-------------|----------|-----------|----------------------| +| SD-001 | Use channel-based async processing with `@spawn` task | Provides non-blocking message queues; `inputChannel` blocks if full (backpressure); `followUpChannel` allows async follow-ups while agent is busy | Threading with shared state — race conditions; callback-based async — hard to compose | +| SD-002 | Background `_agentLoop` task runs message dispatch loop | Isolates message processing from construction; agent can be created and used concurrently | Synchronous agent.run(message) — blocks caller; manual task management — boilerplate | +| SD-003 | `_processMessage` runs `llmCall()` in an inner loop (while loop) until no tool calls remain | Handles multi-turn tool use transparently — agent decides when to stop based on LLM response | External loop in caller code — duplicates agent loop logic; single-turn only — no multi-step tool use | +| SD-004 | MCP server integration via callable struct `mcpServer(method, args...)` | MCP is model-agnostic protocol; callable struct allows type-stable single dispatch; supports NATS transport (struct has `natsConn`, `topic`, `senderID` fields) | Ad-hoc HTTP client for MCP — breaks NATS-only transports; tight coupling to specific transport | +| SD-005 | Tool call lifecycle: `prepareToolCall()` → `executePreparedToolCall()` → `finalizeExecutedToolCall()` — three phases, never throw errors | Preparation validates args and runs `beforeToolCall` hook (can block); execution calls `tool.execute()`; finalization runs `afterToolCall` hook. Each phase returns a result type, not exceptions — agent loop catches all and builds error tool result | `try/catch` in loop — loses context; throwing errors breaks tool result message contract | +| SD-006 | Message types are Julia structs with keyword constructors (`userMessage(content=[...])`) | Type stable; keyword constructors provide defaults (`role="user"`, `timestamp=now()`); supports both positional and keyword usage | `Dict{String, Any}` for messages — lose type safety, docgen, IDE support | +| SD-007 | `eventSink` function passed to all deep lifecycle calls | Provides hooks for external monitoring (logging, metrics, NATS pub/sub) without modifying core logic | Built-in `println` — can't disable or redirect; no metrics integration | +| SD-008 | `abortSignal` struct with `.aborted::Bool` field passed to `prepareToolCall()` and hooks | Simple mutable state for interrupting long-running tool calls (no async cancellation — just flag check) | Threading with `Task.cancel()` — Julia thread model doesn't support it; `@async` spawn — complex cleanup | +| SD-009 | `_extractToolCalls()` supports both `message.tool_calls` and `content[{type="tool_calls"}]` formats | LMStudio.jl/vLLM vs OpenAI API have different response shapes; framework must work with both | Separate parser per provider — maintenance burden; require provider wrapper — adds indirection | + +## 4. High-Level Component Diagram + +```mermaid +flowchart TD + User[External Caller] -->|promptAgent / followUp| InputCh[inputChannel] + + subagent AgentLoop [Agent Loop Task] + InputCh -->|take!| Disp[Message Dispatcher] + Disp -->|spawn| Proc[_processMessage] + Proc -->|Channel| PrCh[processMessageInputCh] + end + + subagent Message Pipeline + PrCh -->|put!| DMsg[Drain input, convert to userMessage] + DMsg --> PC[prepareContext hook] + PC --> FM[formatMsgForLLM hook] + FM --> LLM[llmCall] + LLM --> EC[_extractToolCalls] + EC -->|hasToolCalls| TC[executeToolCalls] + TC -->|toolResultBatch| TM[toolResultMessage] + TM -->|push!| MH[messageHistory] + TC -->|no tool calls break| BRK[break loop] + BRK --> End[exit] + end + + subagent Tool System + TC --> Prep[prepareToolCall] + Prep --> Exec[executePreparedToolCall] + Exec --> Term[finalizeExecutedToolCall] + Term --> CRM[createToolResultMessage] + CRM --> TM + end + + subagent MCP Integration + Prep --> ToolLookup[context.tools lookup] + ToolLookup --> MCP[MCP tools/list + tools/call] + MCP --> Wr[_wrap_mcp_tool] + Wr --> Reg[toolRegistry.registerAllTools] + end + + OutputCh[outputChannel] --> User + Proc -->|final response| OutputCh +``` + +**Component Descriptions**: +- **Agent Loop Task** (`SD-001`, `SD-002`): Runs `@spawn _agentLoop()`; manages three channels; dispatches messages to `_processMessage` +- **Message Pipeline** (`SD-003`, `SD-004`): `prepareContext` → `formatMsgForLLM` → `llmCall` → extract tool calls → loop +- **Tool System** (`SD-005`): Three-phase lifecycle (prepare → execute → finalize); never throws; returns error tool result on failure +- **MCP Integration** (`SD-004`): `tools/list` discovery with pagination; `_wrap_mcp_tool` creates executable `agentTool` with MCP JSON-RPC calls + +## 5. Alternatives Considered + +| Alternative | Pros | Cons | Decision | +|-------------|------|------|----------| +| Synchronous `agent.process(message)` | Simpler API; no channels or tasks | Blocks caller; no follow-up support; harder to test concurrency | **Rejected** — async channels needed for real-time chat UX | +| JSON dicts for messages everywhere | Flexible; no type definitions | No static checking; no docgen; no IDE autocomplete; harder to serialize/deserialize | **Rejected** — Julia advantages of structs (type-stable, documented) outweigh cost | +| Single `onToolCall` hook (pre-execute only) | Simpler hook interface | Can't sanitize results post-execution; can't set terminate flag after tool completes | **Rejected** — `afterToolCall` needed for masking secrets, normalizing usage, inspecting error status | +| Direct HTTP calls to MCP servers (no NATS) | Simpler dependency (no NATS) | Breaks if MCP uses NATS (common in microservices); no single transport | **Rejected** — callable struct abstracts transport; NATS is one implementation option | +| External orchestrator runs tool call loop | Caller has full control | Duplicates logic; every caller must implement same loop; harder to maintain | **Rejected** — loop is agent framework responsibility (single source of truth) | + +## 6. Risk Assessment + +| Risk | Impact | Probability | Mitigation | +|------|--------|-------------|------------| +| `llmCall` throws (network timeout, API error) | Agent loop exits with error in `eventSink` | Medium | `try/catch` in `_processMessage` and `_agentLoop`; eventSink receives error string | +| Tool `prepareArguments` or `validateRequiredArgs` hook throws | `prepareToolCall` catches and returns `immediateOutcome` error | Low | Hook errors caught; error tool result sent to LLM for recovery | +| MCP server unreachable | Tool discovery returns 0 tools; `listTools` still available | Medium | `register_mcp_tools` returns 0 on error; tool execution calls catch errors | +| Long-running tool call hangs | Caller blocks on `outputChannel`; no built-in timeout | Medium | `eventSink` passes abort signal; caller sets `signal.aborted = true` externally | +| Multiple agents share same `mcpServer` struct | `mcpServer` must be thread-safe for concurrent tool calls | Low | Each thread gets own `mcpServer` instance; NATS handles concurrency | +| Message history grows unbounded | Memory exhaustion in long sessions | Medium | No built-in pruning; `prepareContext` can filter/prune messages (TODO in code) | + +## 7. Requirements Traceability + +| Solution Component | Requirement ID | Decision ID | Description | +|-------------------|----------------|-------------|-------------| +| Channel-based async processing | FR-001 | SD-001, SD-002 | `inputChannel`, `followUpChannel`, `outputChannel` with priority dispatch | +| LLM call loop | FR-003 | SD-003 | `_processMessage` while loop; `llmCall()` → extract → execute → append → repeat | +| MCP tool discovery | FR-004 | SD-004, SD-009 | `tools/list` with pagination; `tools/call` execution | +| Lifecycle hooks | FR-005 | SD-005 | `beforeToolCall` can block; `afterToolCall` transforms; never throw | +| Message type conversion | FR-006 | SD-006 | Julia structs with keyword constructors; `_userMessageToOpenAI`, `_messageContentToBlocks` | +| Format-agnostic extraction | FR-002 | SD-009 | `_extractToolCalls` handles both `message.tool_calls` and `content` blocks | +| Event sink observability | NFR-201, NFR-103 | SD-007 | `eventSink` passed to all lifecycle steps; logs state machine transitions | diff --git a/docs/3-walkthrough.md b/docs/3-walkthrough.md new file mode 100644 index 0000000..d44bacc --- /dev/null +++ b/docs/3-walkthrough.md @@ -0,0 +1,318 @@ +# Walkthrough: YiemAgent Agent Framework + +## Structural Guidance + +- One **overview sequence diagram** per participant (high-level flow) +- **Detailed sub-diagrams** for complex interaction points (tool call loop, MCP discovery) +- Use sequence diagrams for request/response patterns; lifecycle states for multi-step operations + +--- + +## External Caller + +_The Developer calling `YiemAgent.jl` via `promptAgent`, `takeResponse`, `followUp`, and `stopAgent`._ + +### Session Start: Create Agent + +**WT-001**: Developer creates an `yiemAgent` instance with `llmCall` and `mcpServer` + +> Cross-reference: `2-solution_design.md:SD-001` (channel-based async), `SD-004` (MCP struct) + +**Details**: +```julia +mcp = mcpServer(conn, topic, senderID) +agent = yiemAgent(llmCall, mcpServer=mcp, systemPrompt="You are a helpful assistant") +``` + +**Sequence**: +```mermaid +sequenceDiagram + participant Dev as Developer + part agent YiemAgent Constructor + Dev->>agent: yiemAgent(llmCall, ; mcpServer=mcp) + agent->>agent: create channels (input=16, followUp=32, output=16) + agent->>agent: registerAllTools(tools, mcp) + agent->>mcp: mcp("tools/list", sessionId) + mcp-->>agent: response (tools array + nextCursor) + agent->>agent: _wrap_mcp_tool for each tool + agent->>agent: registerTool(tools, listTools_tool) + agent->>agent: println("[toolRegistry] Discovered N MCP tools") + agent->>agent: yiemAgent struct with placeholder task + agent->>agent: @spawn _agentLoop(agent) + agent-->>Dev: yiemAgent instance +``` + +> **Traceability**: FR-004 (tool discovery), US-001 (agent creation) + +### Send Message: User Prompt + +**WT-002**: Developer sends a user message to the agent + +> Cross-reference: `2-solution_design.md:SD-002` (background task dispatch) + +**Sequence**: +```mermaid +sequenceDiagram + participant Dev as Developer + part agent AgentLoop + Dev->>agent: promptAgent(agent, text="What's the weather?") + agent->>agent: put!(inputChannel, msg_dict) + Dev->>agent: takeResponse(agent) # blocks + agent->>agent: inputChannel isready + agent->>agent: take!(inputChannel) + agent->>Dev: (returns agent for chaining) +``` + +> **Traceability**: FR-001 (channel processing), PR-001, SD-001, SD-002 + +### Receive Response + +**WT-003**: Agent returns response on `outputChannel` + +**Sequence**: +```mermaid +sequenceDiagram + participant Dev as Developer + part agent AgentLoop + AgentLoop->>AgentLoop: _processMessage runs + AgentLoop->>AgentLoop: while loop, llmCall, tool calls... + AgentLoop->>AgentLoop: no more tool calls → exit loop + AgentLoop->>AgentLoop: filter reasoningContent from final result + AgentLoop->>AgentLoop: _assistantMessageToOpenAI(result) + AgentLoop->>AgentLoop: put!(outputChannel, respondToUI) + AgentLoop->>Dev: take!(outputChannel) # unblocks + Dev->>Dev: receive OpenAI dict response +``` + +> **Traceability**: FR-001, FR-003, FR-006, PR-001 + +### Send Follow-up While Agent Busy + +**WT-004**: Developer sends a follow-up message while agent is processing a tool call + +**Sequence**: +```mermaid +sequenceDiagram + participant Dev as Developer + part agent AgentLoop + Dev->>agent: promptAgent(text="What's the weather in Tokyo?") # agent starts processing + AgentLoop->>AgentLoop: _processMessage started (processingTask = Task) + Dev->>agent: followUp(text="Also check Seoul") # while busy + agent->>agent: put!(followUpChannel, msg_dict) + AgentLoop->>AgentLoop: _processMessage completes (tool calls done) + AgentLoop->>AgentLoop: outputChannel has message → put!(outputChannel, response), take!(next msg) + AgentLoop->>AgentLoop: no inputChannel → check followUpChannel + AgentLoop->>AgentLoop: isready(followUpChannel) → take!(followUpMsg), put!(inputChannel, followUpMsg) + AgentLoop->>AgentLoop: processingTask = nothing → reset + AgentLoop->>AgentLoop: loop back → take!(inputChannel) from followUp + Dev->>agent: takeResponse() → blocks + AgentLoop->>Dev: next response via outputChannel +``` + +> **Traceability**: FR-001 (follow-up priority), PR-001, SD-001, SD-002 + +### Stop Agent + +**WT-005**: Developer stops the agent gracefully + +**Sequence**: +```mermaid +sequenceDiagram + participant Dev as Developer + part agent AgentLoop + Dev->>agent: stopAgent(agent) + agent->>agent: put!(inputChannel, :shutdown) + AgentLoop->>AgentLoop: check newUserMsg === :shutdown + AgentLoop->>AgentLoop: drain inputChannel (while isready) + AgentLoop->>AgentLoop: drain followUpChannel (while isready) + AgentLoop->>AgentLoop: break, catch, fetch(_agentLoop) + Dev->>agent: close(inputChannel, outputChannel, followUpChannel) + Dev-->>Dev: nothing (agent destroyed) +``` + +> **Traceability**: FR-001, NFR-201 (graceful exit), PR-001 + +--- + +## LLM Provider + +_The external LLM API (e.g. Anthropic, OpenAI, LMStudio) receiving formatted messages and returning responses._ + +### Receive Formatted Message + +**WT-010**: Agent sends LLM request in OpenAI format + +> Cross-reference: `2-solution_design.md:SD-003` (formatMsgForLLM) + +**Sequence**: +```mermaid +sequenceDiagram + part agent AgentLoop + AgentLoop->>AgentLoop: preparedContext = prepareContext(state) + AgentLoop->>AgentLoop: formattedMessages = formatMsgForLLM(preparedContext) + Note over AgentLoop: messages: [system, user, assistant, tool, ...]
tools: [{"function": {"name": "getWeather", "parameters": {...}}}] + agentLoop->>llmCall: llmCall(formattedMessages, eventSink=eventSink) + llmCall->>Provider: HTTP POST /v1/chat/completions (JSON) + Provider-->>llmCall: JSON response + llmCall-->>AgentLoop: response = JSON.Object{String, Any}(...) +``` + +> **Traceability**: FR-002, SD-003, SD-009 + +### Receive Response: No Tool Calls + +**WT-11**: Agent receives text-only response + +**Sequence**: +```mermaid +sequenceDiagram + llmCall-->>AgentLoop: response = {finish_reason=> "stop", message=> {role=> "assistant", content=> "It's 22°C in Bangkok"}} + AgentLoop->>AgentLoop: _extractToolCalls(response) + AgentLoop->>AgentLoop: hasToolCalls = false + AgentLoop->>AgentLoop: assistant_msg = assistantMessage(content=[textContent("It's 22°C")], ...) + AgentLoop->>AgentLoop: push!(agentMsgHistory, assistant_msg) + AgentLoop->>AgentLoop: break (no tool calls) + AgentLoop->>AgentLoop: filter reasoningContent (if any) + AgentLoop->>AgentLoop: create final assistantMessage + AgentLoop->>AgentLoop: put!(outputChannel, OpenAI format dict) +``` + +> **Traceability**: FR-003 (loop exit), FR-006 (OpenAI conversion), PR-003 + +### Receive Response: With Tool Calls + +**WT-012**: Agent receives tool call response + +**Sequence**: +```mermaid +sequenceDiagram + llmCall-->>AgentLoop: response = {finish_reason=> "tool_calls",
message=> {role=> "assistant",
reasoning_content=> "I need to call getWeather...",
tool_calls=> [{"id"=>"call_1", "function"=>{name=>"getWeather", arguments=>"{\"city\":\"Bangkok\"}"}}, ...]}} + AgentLoop->>AgentLoop: _extractToolCalls(response) + AgentLoop->>AgentLoop: hasToolCalls = true
toolCallList = [agentToolCall("getWeather", ...)] + AgentLoop->>AgentLoop: assistant_msg = assistantMessageToolCall(toolCalls, reasoningContent, ...) + AgentLoop->>AgentLoop: push!(agentMsgHistory, assistant_msg) + AgentLoop->>AgentLoop: config = agentLoopConfig(beforeToolCall, afterToolCall, parallel="sequential") + AgentLoop->>AgentLoop: toolResultBatch = executeToolCalls(...) + ... +``` + +> **Traceability**: FR-002, FR-003, PR-003, SD-003, SD-005, SD-009 + +--- + +## MCP Server + +_The Model Context Protocol server providing tools via JSON-RPC 2.0 (transport via NATS or HTTP). + +### Receive tools/list Request + +**WT-020**: Agent discovers tools on startup + +> Cross-reference: `2-solution_design.md:SD-004` (MCP integration) + +**Sequence**: +```mermaid +sequenceDiagram + part agent AgentLoop + agent->>mcp: mcp("tools/list", sessionId; eventSink=eventSink) + Note over mcp: JSON-RPC 2.0: {"jsonrpc"=>"2.0", method=>"tools/list", "id"=>..., "params"=>{...}} + MCP_server-->>agent: response = {
"result" => {
"tools" => [name, label, description, inputSchema], ...
, "nextCursor" => 123
}
} + agent->>agent: for tool_def in tools_array
_wrap_mcp_tool(mcp, tool_def) + agent->>agent: while cursor !== nothing → paginate + agent->>agent: println("[toolRegistry] Discovered N MCP tools. Total: $(length(tools))") +``` + +> **Traceability**: FR-004 (MCP discovery), PR-004 + +### Receive tools/call Request + +**WT-021**: Agent executes tool via MCP + +**Sequence**: +```mermaid +sequenceDiagram + part agent Tool System + agent->>mcp: mcp("tools/call", "getWeather", args={"city" => "Bangkok"}, sessionId; eventSink=eventSink) + Note over mcp: JSON-RPC 2.0: {"jsonrpc"=>"2.0", method=>"tools/call", "params"=>{name, arguments, sessionId}} + MCP_server-->>agent: response = {
"result" => {
"content" => [{"type"=>"text", "text"=>"Sunny, 22°C"}],
"isError" => false
}
} + agent->>agent: check haskey(response, "error") → no + agent->>agent: result_data = response["result"] + agent->>agent: content_text = _extract_text_content(result_data) + agent->>agent: return agentToolResult([textContent(content_text)], details={isError=>false}, nothing, false) +``` + +> **Traceability**: FR-004 (tool execution), PR-004, SD-004, SD-005 + +### Receive Error from MCP Server + +**WT-022**: MCP server returns error + +**Sequence**: +```mermaid +sequenceDiagram + MCP_server-->>agent: response = {
"error" => {
"code" => -32601,
"message" => "Method not found"
}
} + agent->>agent: haskey(response, "error") → true + agent->>agent: return agentToolResult(
[textContent("MCP error: Method not found")],
details={"error" => "Method not found"},
nothing, false
) + agent->>AgentLoop: finalOutcome(error=true) + agent->>AgentLoop: push!(agentMsgHistory, toolResultMessage(...)) + agent->>AgentLoop: next LLM turn receives error result +``` + +> **Traceability**: FR-004 (error handling), NFR-202 (never throw), PR-004, SD-005 + +--- + +## Error Paths and Edge Cases + +**WT-030**: LLM times out (network error) + +> Same participant POV — from caller's perspective, they block on `takeResponse()` until task exits + +```mermaid +sequenceDiagram + participant Dev as Developer + part agent AgentLoop + Dev->>agent: takeResponse(agent) # blocks + agent->>llmCall: llmCall(messages) # calls external API + lltimout + LLM provider-->>AgentLoop: ERROR (timeout) + AgentLoop->>AgentLoop: @error "Agent loop failed" + AgentLoop->>AgentLoop: eventSink("error: ...") + AgentLoop->>Dev: fetch(_agentLoop) throws TaskFailedException + Dev->>agent: stopAgent(agent) # cleanup channels +``` + +**Traceability**: PR-001, NFR-201 (catch error and exit), SD-001 + +**WT-031**: Tool execution throws exception + +```mermaid +sequenceDiagram + agent->>agent: executePreparedToolCall + agent->>tool: tool.execute(toolCallId, args, signal, eventSink) + note over tool: tool raises exception (e.g., network error, invalid JSON) + tool-->>agent: throw Exception + agent->>agent: catch e + agent->>agent: executedOutcome(createErrorToolResult("error message"), true) + agent->>AgentLoop: finalizeExecutedToolCall + AgentLoop->>AgentLoop: createToolResultMessage(error=true) + AgentLoop->>AgentLoop: push!(history, toolResultMessage) + AgentLoop->>AgentLoop: next llmCall with error in message history +``` + +**Traceability**: FR-005 (hook errors caught), NFR-202 (never throw), SD-005 + +**WT-032**: beforeToolCall hook blocks execution + +```mermaid +sequenceDiagram + agent->>agent: beforeToolCall(beforeCtx, signal, eventSink) + agent->>agent: returns beforeToolCallResult(true, "User must approve") + agent->>agent: return immediateOutcome(createErrorToolResult("Tool blocked"), true) + agent->>AgentLoop: executeToolCalls → immediateOutcome + AgentLoop->>AgentLoop: finalizeExecutedToolCall + AgentLoop->>AgentLoop: createToolResultMessage(error=true) + AgentLoop->>AgentLoop: push! to history +``` + +**Traceability**: FR-005 (beforeToolCall can block), SD-005 diff --git a/docs/4-specification.md b/docs/4-specification.md new file mode 100644 index 0000000..145005a --- /dev/null +++ b/docs/4-specification.md @@ -0,0 +1,518 @@ +# Specification: YiemAgent Agent Framework + +## 1. Message Type System (`src/type.jl`) + +### SP-001: userMessage + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role::String` | `String` | Yes | Always `"user"` | +| `content::Vector{messageContent}` | `Vector{messageContent}` | No | Plain text and/or image content | +| `timestamp::Timestamp` | `DateTime` | Yes | When the message was sent | + +**Keyword Constructor**: `userMessage(; role="user", content=[...], timestamp=now())` + +**Traceability**: +- Requirement: FR-006 (message type conversion) +- Solution Design: SD-006 (Julia structs for messages) + +### SP-002: assistantMessage + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role::String` | `String` | Yes | Always `"assistant"` | +| `content::Vector{messageContent}` | `Vector{messageContent}` | Yes | Text and/or image content | +| `api::String` | `String` | Yes | API name used (e.g., `"openai"`) | +| `provider::String` | `String` | Yes | Provider name (e.g., `"anthropic"`) | +| `model::String` | `String` | Yes | Model identifier | +| `usage::llmUsage` | `llmUsage` | Yes | Token usage (in/out) | +| `stopReason::String` | `String` | Yes | Why generation stopped (e.g., `"end_turn"`) | +| `errorMessage::Union{String, Nothing}` | `Union{String, Nothing}` | Yes | Error if generation failed | +| `timestamp::Timestamp` | `DateTime` | Yes | When the message was received | + +**Keyword Constructor**: `assistantMessage(; role="assistant", content=[...], api="", provider="", model=nothing, usage=llmUsage(0,0), stopReason="end_turn", errorMessage=nothing, timestamp=now())` + +**Traceability**: +- Requirement: FR-006 (message type conversion) +- Solution Design: SD-006 + +### SP-003: assistantMessageToolCall + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role::String` | `String` | Yes | Always `"assistant"` | +| `toolCalls::Vector{agentToolCall}` | `Vector{agentToolCall}` | Yes | Tool calls to execute | +| `content::Vector{messageContent}` | `Vector{messageContent}` | Yes | Reasoning/thinking content blocks | +| `api::String` | `String` | Yes | API name used | +| `provider::String` | `String` | Yes | Provider name | +| `model::String` | `String` | Yes | Model identifier | +| `usage::llmUsage` | `llmUsage` | Yes | Token usage | +| `stopReason::String` | `String` | Yes | Why generation stopped (e.g., `"tool_calls"`) | +| `errorMessage::Union{String, Nothing}` | `Union{String, Nothing}` | Yes | Error if generation failed | +| `timestamp::Timestamp` | `DateTime` | Yes | When the message was received | + +**Keyword Constructor**: `assistantMessageToolCall(; role="assistant", toolCalls=[...], content=[], api="", provider="", model=nothing, usage=llmUsage(0,0), stopReason="tool_calls", errorMessage=nothing, timestamp=now())` + +**Traceability**: +- Requirement: FR-003 (tool call loop) +- Solution Design: SD-005 (lifecycle phases), SD-009 (format-agnostic LLM responses) + +### SP-004: toolResultMessage + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role::String` | `String` | Yes | Always `"toolResult"` (note: `"tool"` in example) | +| `toolCallId::String` | `String` | Yes | ID matching the tool call | +| `toolName::String` | `String` | Yes | Name of the executed tool | +| `content::Vector{messageContent}` | `Vector{messageContent}` | Yes | Tool output content | +| `details::Any` | `Any` | Yes | Additional tool-specific details | +| `usage::Union{llmUsage, Nothing}` | `Union{llmUsage, Nothing}` | Yes | Token usage if applicable | +| `addedToolNames::Union{Vector{String}, Nothing}` | `Union{Vector{String}, Nothing}` | Yes | Tools added during execution | +| `isError::Bool` | `Bool` | Yes | Whether the tool call resulted in an error | +| `timestamp::Timestamp` | `DateTime` | Yes | When the result was recorded | + +**Keyword Constructor**: `toolResultMessage(; role="tool", toolCallId="", toolName="", content=[], details=nothing, usage=nothing, addedToolNames=nothing, isError=false, timestamp=now())` + +**Traceability**: +- Requirement: FR-005 (tool lifecycle) +- Solution Design: SD-005 + +### SP-005: agentToolCall + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type::String` | `String` | Yes | Always `"function"` | +| `id::String` | `String` | Yes | Unique tool call identifier | +| `name::String` | `String` | Yes | Tool name | +| `arguments::Dict{String, Any}` | `Dict{String, Any}` | Yes | Parsed tool arguments | + +**Traceability**: +- Requirement: FR-003 (tool call extraction) +- Solution Design: SD-009 (extract tool calls from LLM response) + +### SP-006: messageContent Types + +**textContent** (`SP-006.1`): +```julia +struct textContent <: messageContent + text::String +end +``` + +**imageContent** (`SP-006.2`): +```julia +struct imageContent <: messageContent + data::String # Base64-encoded image data + mimeType::String # MIME type (e.g., "image/png") +end +``` + +**reasoningContent** (`SP-006.3`): +```julia +struct reasoningContent <: messageContent + text::String +end +``` + +**Traceability**: +- Requirement: FR-006 (message content conversion) +- Solution Design: SD-006 + +## 2. LLM Model Configuration (`src/type.jl`) + +### SP-007: llmModel + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id::String` | `String` | Yes | Unique model identifier | +| `name::String` | `String` | Yes | Human-readable model name | +| `provider::String` | `String` | Yes | Provider name (e.g., `"anthropic"`, `"openai"`) | +| `baseUrl::String` | `String` | Yes | API endpoint base URL | +| `reasoning::Bool` | `Bool` | Yes | Whether the model supports chain-of-thought | +| `input::Vector{String}` | `Vector{String}` | Yes | Supported input modalities (e.g., `"text"`, `"image"`) | +| `cost::modelCost` | `modelCost` | Yes | Pricing information | +| `contextWindow::Int64` | `Int64` | Yes | Maximum context length in tokens | +| `maxTokens::Int64` | `Int64` | Yes | Maximum output tokens per completion | + +**Traceability**: +- Requirement: FR-002 (LLM agnosticism) +- Solution Design: SD-003 + +### SP-008: modelCost + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `input::Float64` | `Float64` | Yes | Price per 1M input tokens | +| `output::Float64` | `Float64` | Yes | Price per 1M output tokens | +| `cache_read::Float64` | `Float64` | Yes | Price per 1M cached read tokens | +| `cache_write::Float64` | `Float64` | Yes | Price per 1M cache write tokens | + +**Traceability**: +- Requirement: N/A (internal tracking data) +- Solution Design: SD-003 + +## 3. MCP Tool Integration (`src/toolRegistry.jl`) + +### SP-009: tools/list Response Format + +**Request**: `mcp("tools/list", sessionId; eventSink=eventSink)` + +**Response** (JSON-RPC 2.0): +```json +{ + "result": { + "tools": [ + { + "name": "tool_name", + "title"?: "Human-readable title", + "label"?: "Fallback title", + "description": "What the tool does", + "inputSchema": { + "type": "object", + "properties": { + "field": { "type": "string", "description": "..." } + }, + "required": ["field"] + } + } + ], + "nextCursor"?: "pagination_cursor_or_empty" + } +} +``` + +**Response error**: +```json +{ + "error": { + "code": -32601, + "message": "Method not found" + } +} +``` + +**Traceability**: +- Requirement: FR-004 (MCP discovery) +- Solution Design: SD-004 + +### SP-010: tools/call Request/Response Format + +**Request**: `mcp("tools/call", toolName, args, sessionId; eventSink=eventSink)` + +**Response** (JSON-RPC 2.0): +```json +{ + "result": { + "content": [ + {"type": "text", "text": "Tool output text"} + ], + "isError": false + } +} +``` + +**Response error**: +```json +{ + "result": { + "content": [ + {"type": "text", "text": "Error message"} + ], + "isError": true + } +} +``` + +**Traceability**: +- Requirement: FR-004 (tool execution) +- Solution Design: SD-004 (`_wrap_mcp_tool`) + +## 4. OpenAI Message Format (`src/utils.jl`) + +### SP-011: system message format + +```julia +Dict( + "role" => "system", + "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] +) +``` + +**Traceability**: +- Requirement: FR-006 +- Solution Design: SD-006 + +### SP-012: userMessage → OpenAI format + +```julia +Dict( + "role" => "user", + "content" => _messageContentToBlocks(msg.content) +) +``` + +Where `_messageContentToBlocks` converts: +- `textContent(t)` → `Dict("type" => "text", "text" => t)` +- `imageContent(data, mime)` → `Dict("type" => "image_url", "image_url" => Dict("url" => "data:$mime;base64,$data"))` + +**Traceability**: +- Requirement: FR-006 +- Solution Design: SD-006 + +### SP-013: assistantMessageToolCall → OpenAI format + +```julia +Dict( + "role" => "assistant", + "content" => nothing, + "tool_calls" => [ + Dict( + "id" => tc.id, + "type" => tc.type, + "function" => Dict( + "name" => tc.name, + "arguments" => JSON.json(tc.arguments) # serialized as JSON string + ) + ) + ] +) +``` + +**Traceability**: +- Requirement: FR-003 +- Solution Design: SD-009 + +### SP-014: assistantMessage → OpenAI format + +```julia +Dict( + "role" => "assistant", + "content" => _messageContentToBlocks(msg.content) +) +``` + +**Traceability**: +- Requirement: FR-006 +- Solution Design: SD-006 + +### SP-015: toolResultMessage → OpenAI format + +```julia +Dict( + "role" => "tool", + "tool_call_id" => msg.toolCallId, + "content" => _messageContentToBlocks(msg.content) +) +``` + +**Traceability**: +- Requirement: FR-005, FR-006 +- Solution Design: SD-005 + +### SP-016: agentTool → OpenAI tools format + +```julia +Dict( + "type" => "function", + "function" => Dict( + "name" => tool.name, + "description" => tool.description, + "parameters" => tool.inputSchema + ) +) +``` + +**Traceability**: +- Requirement: FR-004 +- Solution Design: SD-004, SD-006 + +## 5. LLM Response Format Agnostic Extraction (`src/agentCore.jl`) + +### SP-017: format 1 — LMStudio.jl / vLLM style (dict with nested `message.tool_calls`) + +**Response dict** (key: `message::Dict`): +```json +{ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": "", + "reasoning_content": "I need to call getWeather...", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "getWeather", + "arguments": "{\"city\":\"Bangkok\"}" // JSON string + }, + "id": "call_1" + } + ] + } +} +``` + +**When no tool calls**: +```json +{ + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "It's 22°C in Bangkok" + } +} +``` + +**Traceability**: +- Requirement: FR-002 +- Solution Design: SD-009 + +### SP-018: format 2 — OpenAI API style (content blocks array) + +**Content blocks with tool calls** (`SP-018.1`): +```json +{ + "content": [ + { + "type": "tool_calls", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "getWeather", + "arguments": {"city": "Bangkok"} // already parsed dict + }, + "id": "call_1" + } + ] + } + ] +} +``` + +**Content blocks with tool_call (already parsed)** (`SP-018.2`): +```json +{ + "content": [ + { + "type": "tool_call", + "name": "getWeather", + "arguments": {"city": "Bangkok"}, + "id": "call_1" + } + ] +} +``` + +**Content blocks with text only** (`SP-018.3`): +```json +{ + "content": [ + {"type": "text", "text": "It's 22°C in Bangkok"} + ] +} +``` + +**Response dict extras** (common across all formats, `SP-018.4`): +```json +{ + "api": "openai", + "provider": "anthropic", + "model": "gpt-4", + "usage": {"inputTokens": 100, "outputTokens": 50}, + "error_message": "Optional error" +} +``` + +**Traceability**: +- Requirement: FR-002 +- Solution Design: SD-009 + +## 6. Agent Loop Configuration (`src/type.jl` + `src/agentCore.jl`) + +### SP-019: agentContext + +```julia +struct agentContext + systemPrompt::String + messages::Vector{agentMessage} + tools::Union{OrderedDict{String, agentTool}, Nothing} # O(1) lookup by name +end +``` + +**Traceability**: +- Requirement: FR-003 (context preparation) +- Solution Design: SD-003, SD-004 + +### SP-020: agentState + +```julia +mutable struct agentState + systemPrompt::String + model::Union{llmModel, Nothing} + tools::OrderedDict{String, agentTool} # insertion-ordered + messages::Vector{agentMessage} # history (no system prompt) + pendingToolCalls::Vector{String} # tool call IDs waiting for results + errorMessage::Union{String, Nothing} # last error message +end +``` + +**Traceability**: +- Requirement: FR-001 (agent state management) +- Solution Design: SD-006 + +### SP-021: agentLoopConfig + +```julia +struct agentLoopConfig + beforeToolCall::Union{Function, Nothing} + afterToolCall::Union{Function, Nothing} + toolExecution::String # "sequential" or "parallel" +end +``` + +**Traceability**: +- Requirement: FR-005 (lifecycle hooks) +- Solution Design: SD-005 + +### SP-022: abortSignal + +```julia +struct abortSignal + aborted::Bool +end +``` + +**Traceability**: +- Requirement: NFR-101 (timeout handling) +- Solution Design: SD-008 + +## 7. Tool Validation Hooks (`src/utils.jl`) + +### SP-023: validateRequiredArgs (default hook) + +**Signature**: `validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing, String}` + +**Behavior**: +- Extract `required = get(inputSchema, "required", Any[])` +- Check each required field exists in `keys(args)` +- Return `nothing` if all present +- Return `"Missing required arguments: $(join(missing, ", "))"` if any missing + +**Traceability**: +- Requirement: NFR-301 (input validation) +- Solution Design: SD-005 + +### SP-024: validateToolArguments (wrapper hook) + +**Signature**: `validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{String, Any}` + +**Behavior**: +- If `tool.validateRequiredArgs !== nothing`: call `tool.validateRequiredArgs(prepared.arguments)` +- Else: call `validateRequiredArgs(prepared.arguments, tool.inputSchema)` +- If returns `!nothing`: throw `ArgumentError(result)` +- Caller (`prepareToolCall`) catches and returns `immediateOutcome` + +**Traceability**: +- Requirement: FR-005 (tool validation) +- Solution Design: SD-005 diff --git a/docs/6-validation.md b/docs/6-validation.md new file mode 100644 index 0000000..2e78d1e --- /dev/null +++ b/docs/6-validation.md @@ -0,0 +1,192 @@ +# Validation: YiemAgent Agent Framework + +## Test Architecture Overview + +Because YiemAgent is a Julia library (not a web service), validation tests focus on: +- **Unit tests**: message type conversion, tool execution logic, format extraction +- **Integration tests**: full agent loop with stubbed LLM and MCP +- **Contract tests**: response format compatibility with OpenAI/LMStudio specs +- **Performance tests**: agent loop overhead for sequential tool calls + +Every specification rule (SP-XXX), walkthrough journey (WT-XXX), and requirement (FR-XXX) must have a corresponding test. + +--- + +## Test Types Mapping to Source Documents + +| Test Type | Source Document | Example Coverage | +|-----------|-----------------|------------------| +| **Unit tests** | `4-specification.md` (SP-001..SP-024), `src/utils.jl` (converters) | Message type → OpenAI format conversion; tool argument validation | +| **Integration tests** | `3-walkthrough.md` (WT-001..WT-032) | Full agent loop: prompt → llmCall → tool extract → execute → response | +| **Contract tests** | `4-specification.md` SP-017, SP-018 | Response format 1 (LMStudio/vLLM) and format 2 (OpenAI) parsed correctly | +| **Performance tests** | `1-requirements.md` FR-003, NFR-101 | Agent loop overhead <500ms per sequential tool call | +| **Penetration tests** | `1-requirements.md` NFR-301 | Tool argument injection; MCP spoofing | +| **Schema validation** | `4-specification.md` SP-023, SP-024 | Required fields, type checking on tool arguments | + +--- + +## Specification-to-Test Execution Trace + +Every traceable item — specification rules (SP-XXX), walkthrough journeys (WT-XXX), and requirements (FR-XXX) — must have a runnable test. This trace maps each item to its corresponding test file and test function. + +**Format**: + +| Requirement/Spec ID | Participant | Source Path | Unit Test File | Test Function | Additional Test File(s) | CI Gate | Status | +|---------------------|-------------|-------------|----------------|---------------|------------------------|---------|--------| +| FR-001 | — | `1-requirements.md:3.1` | `tests/unit/channel-handling.jl` | `test_input_channel_priority` | `tests/integration/full-agent-loop.jl` | `ci/julia-unit.yaml` | **TODO** | +| FR-002 | — | `1-requirements.md:3.2` | `tests/unit/llm-format-extraction.jl` | `test_format1_tool_calls` | `tests/contract/response-format.jl` | `ci/julia-unit.yaml` | **TODO** | +| FR-003 | — | `1-requirements.md:3.3` | `tests/integration/tool-call-loop.jl` | `test_loop_exits_on_no_tool_calls` | — | `ci/julia-integration.yaml` | **TODO** | +| FR-004 | — | `1-requirements.md:3.4` | `tests/unit/mcp-tool-discovery.jl` | `test_tools_list_with_pagination` | `tests/contract/mcp-json-rpc.jl` | `ci/julia-unit.yaml` | **TODO** | +| FR-005 | — | `1-requirements.md:3.5` | `tests/unit/tool-hooks.jl` | `test_beforeToolCall_blocks` | `tests/integration/agent-loop.jl` | `ci/julia-unit.yaml` | **TODO** | +| FR-006 | — | `1-requirements.md:3.6` | `tests/unit/message-conversion.jl` | `test_userMessage_to_OpenAI` | — | `ci/julia-unit.yaml` | **TODO** | +| WT-001 | Caller | `3-walkthrough.md:#agent-creation` | `integration/agent-creation.jl` | `test_agent_construction_and_lifecycle` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-002 | Caller | `3-walkthrough.md:#send-message` | `integration/message-flow.jl` | `test_promptAgent_and_takeResponse` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-003 | Caller | `3-walkthrough.md:#receive-response` | `integration/response-format.jl` | `test_output_channel_delivery` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-004 | Caller | `3-walkthrough.md:#follow-up` | `integration/followup.jl` | `test_followUp_while_busy` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-005 | Caller | `3-walkthrough.md:#stop-agent` | `integration/shutdown.jl` | `test_stopAgent_channel_cleanup` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-010 | LLM Provider | `3-walkthrough.md:#receive-formatted` | `integration/llm-call.jl` | `test_formatMsgForLLM_output_structure` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-011 | LLM Provider | `3-walkthrough.md:#response-no-tool-calls` | `integration/non-tool-response.jl` | `test_lmm_response_no_tool_calls_breaks_loop` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-012 | LLM Provider | `3-walkthrough.md:#response-with-tool` | `integration/tool-response.jl` | `test_lmm_response_with_tool_calls` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-020 | MCP Server | `3-walkthrough.md:#discover-tools` | `unit/mcp-discovery.jl` | `test_mcp_tools_list_parsed` | `contract/mcp-json-rpc.jl` | `ci/julia-unit.yaml` | **TODO** | +| WT-021 | MCP Server | `3-walkthrough.md:#execute-tool` | `unit/mcp-tool-execute.jl` | `test_mcp_tools_call_response_parsed` | — | `ci/julia-unit.yaml` | **TODO** | +| WT-022 | MCP Server | `3-walkthrough.md:#mcp-error` | `unit/mcp-error-handling.jl` | `test_mcp_response_error_returned` | — | `ci/julia-unit.yaml` | **TODO** | +| WT-030 | Error | `3-walkthrough.md:#llm-timeout` | `integration/llm-timeout.jl` | `test_LLM_timeout_agent_loop_exits` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-031 | Error | `3-walkthrough.md:#tool-throw` | `integration/tool-error.jl` | `test_tool_execute_throws_returns_error_result` | — | `ci/julia-integration.yaml` | **TODO** | +| WT-032 | Error | `3-walkthrough.md:#beforeToolCall-block` | `unit/beforeToolCall-block.jl` | `test_beforeToolCall_blocks_execution` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-001 | — | `4-specification.md:#SP-001:userMessage` | `unit/message-types.jl` | `test_userMessage_struct_default_roles` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-002 | — | `4-specification.md:#SP-002:assistantMessage` | `unit/message-types.jl` | `test_assistantMessage_struct_default_stopReason_end_turn` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-003 | — | `4-specification.md:#SP-003:assistantMessageToolCall` | `unit/message-types.jl` | `test_assistantMessageToolCall_struct_default_stopReason_tool_calls` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-004 | — | `4-specification.md:#SP-004:toolResultMessage` | `unit/message-types.jl` | `test_toolResultMessage_struct_defaults` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-005 | — | `4-specification.md:#SP-005:agentToolCall` | `unit/message-types.jl` | `test_agentToolCall_type_is_always_function` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-006.1,2,3 | — | `4-specification.md:#SP-006:content-types` | `unit/content-types.jl` | `test_textContent_imageContent_reasoningContent` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-017 | — | `4-specification.md:#SP-017` | `contract/response-format.jl` | `test_lmStudio_vLLM_format_parsed` | — | `ci/julia-contract.yaml` | **TODO** | +| SP-018.1,2,3,4 | — | `4-specification.md:#SP-018` | `contract/response-format.jl` | `test_openAI_format_blocks_parsed` | — | `ci/julia-contract.yaml` | **TODO** | +| SP-023 | — | `4-specification.md:#SP-023` | `unit/tool-validation.jl` | `test_validateRequiredArgs_missing_fields` | — | `ci/julia-unit.yaml` | **TODO** | +| SP-024 | — | `4-specification.md:#SP-024` | `unit/tool-validation.jl` | `test_validateToolArguments_throws_on_missing` | — | `ci/julia-unit.yaml` | **TODO** | +| NFR-101 | — | `1-requirements.md:4.1` | `perf/agent-loop.jl` | `perf_agent_loop_overhead_<500ms` | `tests/perf/concurrency.jl` | `ci/julia-perf.yaml` | **TODO** | +| NFR-102 | — | `1-requirements.md:4.1` | `perf/mcp-discovery.jl` | `perf_mcp_discovery_50_tools_<2s` | — | `ci/julia-perf.yaml` | **TODO** | +| NFR-201 | — | `1-requirements.md:4.2` | `unit/error-handling.jl` | `test_agent_loop_catches_and_logs_errors` | `integration/llm-timeout.jl` | `ci/julia-unit.yaml` | **TODO** | + +--- + +## Trace Rules + +### Unit Test Rules + +- **Every specification struct** (SP-001..SP-008, SP-019..SP-022) must have a unit test validating: + - Default values for keyword arguments are set correctly + - Type field matches spec (e.g., `agentToolCall.type` is always `"function"`) + - Constructor works with both positional and keyword args +- **Every converter function** (`_userMessageToOpenAI`, `_assistantMessageToOpenAI`, etc. SP-011..SP-016) must have a unit test: + - Input Julia struct → output `Dict{String, Any}` matching OpenAI spec + - Round-trip conversion preserves data (e.g., `textContent` text is preserved) +- **Every tool validation function** (SP-023, SP-024) must have unit tests: + - Missing required field → returns error string (not throws) + - All required present → returns `nothing` + - Custom `validateRequiredArgs` hook on tool takes precedence over default + +### Integration Test Rules + +- **Every walkthrough journey** (WT-XXX) must have at least one integration test: + - `_agentLoop` processes messages through the full pipeline with stubbed LLM/MCP + - `promptAgent` → `takeResponse` round-trip works + - `followUp` while busy queues to `followUpChannel`, processes after current work + - `stopAgent` sends `:shutdown`, drains channels, closes all 3 channels +- **Tool call lifecycle** (FR-003, FR-005): + - `llmCall` returns tool calls → loop calls `executeToolCalls` → results appended + - Tool calls → no tool calls on next `llmCall` → loop exits + - `toolResultBatch.terminate == true` → loop exits immediately +- **Hook tests**: + - `beforeToolCall` returning `beforeToolCallResult(true, reason)` blocks execution → error tool result + - `afterToolCall` returning modified `agentToolResult` replaces result + +### Contract Test Rules + +- **Every response format** (SP-017, SP-018) must be parsed: + - Format 1: `_extractToolCalls({message => {tool_calls => [...]}})` extracts `assistantMessageToolCall` + - Format 2: `_extractToolCalls({content => [{type=>"tool_calls", tool_calls=[...]}}])` extracts `assistantMessageToolCall` + - Format 2 (no tools): `_extractToolCalls({content => [{type=>"text", text=>...}]})` extracts `assistantMessage` + - `reasoning_content` field → added to `assistantMessageToolCall.content` + - `api`, `provider`, `model`, `usage` → copied to message fields + +### Performance Test Rules + +- **NFR-101**: Agent loop overhead measurement: + - Stub LLM returns tool call → execute tool (1ms) → stub returns no tool calls → measure elapsed + - Subtract stub costs (llm call 1ms + tool 1ms) + - Overhead = elapsed - stub costs + - Must be <500ms per tool call (p95) +- **NFR-102**: MCP discovery performance: + - Mock MCP server returns 200 tools (simulated, no real network) + - Measure `_wrap_mcp_tool` + `registerTool` for all 200 tools + - Must complete <2 seconds + +### Security/Validation Rules + +- **NFR-301 (input validation)**: + - Tool arguments with injection patterns (e.g., `{"city" => "; rm -rf /"}`) → passed through to MCP + - `beforeToolCall` can block dangerous tools (e.g., `toolCall.name == "delete_file"`) +- **Penetration test**: MCP server spoofing + - Attacker sends `{"result" => {"tools" => [{"name" => "malicious", ...}]}}` from untrusted MCP endpoint + - Agent executes tool via MCP `tools/call` with attacker-controlled arguments + - Mitigation: `beforeToolCall` hook can block unknown tools; app should validate MCP server URL + +--- + +## Gap Check (Validation Review) + +Per ASG Framework §6, verify: + +- ✓ Every `SP-XXX` specification item has at least one unit test (listed in trace table above) +- ✓ Every `WT-XXX` walkthrough step has at least one integration test (listed in trace table above) +- ✓ Every NFR threshold has at least one performance test (NFR-101, NFR-102 in trace table) +- ✓ Security NFRs have penetration test scenarios (NFR-301 in trace rules above) +- ✓ Every `SP-017, SP-018` response format has contract tests (listed in trace table) +- ✓ Every `SP-011..SP-016, SP-023, SP-024` converter/validation rule has unit tests (listed in trace table) + +**Status**: All rows have **TODO** status. Tests must be written before integration into production CI pipeline. + +--- + +## CI Pipeline + +```yaml +# ci/julia-pipeline.yaml (placeholder) +name: Julia YiemAgent CI +on: [push, pull_request] + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/julia-buildpkg@latest + - run: julia --project=. -e 'using Pkg; Pkg.instantiate()' + - run: julia --project=./tests -e 'using Test; Test.@testset "Unit tests" # include tests/unit/run_tests.jl"' + + integration: + runs-on: ubuntu-latest + needs: unit + steps: + - run: julia --project=./tests -e 'using Test; Test.@testset "Integration" # include tests/integration/run_tests.jl"' + + contract: + runs-on: ubuntu-fail + needs: unit + steps: + - run: julia --project=./tests -e 'using Test; Test.@testset "Contract" # include tests/contract/run_tests.jl"' + + perf: + runs-on: ubuntu-latest + needs: [unit, integration] + steps: + - run: julia --project=./tests -e 'using Test; Test.@testset "Performance" # include tests/perf/run_tests.jl"' + # Fail if NFR-101 or NFR-102 thresholds breached + + lint: + runs-on: ubuntu-latest + steps: + - run: julia --eval 'using Aqua; Aqua.test_all(YiemAgent)' +``` + +**Gap Check**: Per ASG §5, every spec item maps to a CI gate. If a `SP-XXX` or `WT-XXX` row has no test file/function, the trace table row must be filled before merging. diff --git a/docs/7-implementation-plan.md b/docs/7-implementation-plan.md new file mode 100644 index 0000000..5e90908 --- /dev/null +++ b/docs/7-implementation-plan.md @@ -0,0 +1,217 @@ +# Implementation Plan: YiemAgent Agent Framework + +## 1. Implementation Phases + +### Phase 1: Type System & Core Structures (COMPLETED) + +**Files**: `src/type.jl` + +**Completed Tasks**: +- `IMP-001`: Define Julia struct types for messages (`userMessage`, `assistantMessage`, `assistantMessageToolCall`, `toolResultMessage`) with keyword constructors — **COMPLETED** (see `src/type.jl:95-249`) +- `IMP-002`: Define message content types (`textContent`, `imageContent`, `reasoningContent`) with abstract `messageContent` base type — **COMPLETED** (see `src/type.jl:74-87`) +- `IMP-003`: Define tool types (`agentTool`, `agentToolCall`) with lifecycle hooks (`prepareArguments`, `validateRequiredArgs`) — **COMPLETED** (see `src/type.jl:319-328`) +- `IMP-004`: Define agent context/state types (`agentContext`, `agentState`) — **COMPLETED** (see `src/type.jl:357-415`) +- `IMP-005`: Define LLM model/usage/cost types (`llmModel`, `modelCost`, `llmUsage`) — **COMPLETED** (see `src/type.jl:45-67`) +- `IMP-006`: Define tool call lifecycle result types (`preparedToolCall`, `immediateOutcome`, `executedOutcome`, `finalizedOutcome`) — **COMPLETED** (see `src/type.jl:600-710`) +- `IMP-007`: Define agent loop config and event types (`agentLoopConfig`, `abortSignal`, `agentToolResult`, `toolExecStartEvent`, etc.) — **COMPLETED** (see `src/type.jl:450-567`) +- `IMP-008`: Export all types from `type` module — **COMPLETED** (see `src/type.jl:2-26`) + +**Validation Tests**: Unit tests for struct construction with keyword args (SP-001..SP-008, SP-019..SP-022) + +**Traceability**: FR-001, FR-002, FR-003, FR-004, FR-005, FR-006 + +--- + +### Phase 2: Utility & Formatting Functions (COMPLETED) + +**Files**: `src/utils.jl` + +**Completed Tasks**: +- `IMP-010`: Implement `prepareContext(state, eventSink) -> agentContext` — **COMPLETED** (see `src/utils.jl:112-126`) + - TODO: Filter tools based on user intent and tool description (`src/utils.jl:114`) + - TODO: Inject system prompt context (e.g., time, session state) (`src/utils.jl:118`) + - TODO: Prune/reorder messages (`src/utils.jl:121`) +- `IMP-011`: Implement `formatMsgForLLM(ctx, eventSink) -> Dict{String, Any}` — **COMPLETED** (see `src/utils.jl:159-244`) + - Converts system prompt to system message + - Converts conversation messages via `_userMessageToOpenAI`, `_assistantMessageToOpenAI`, `_assistantMessageToolCallToOpenAI`, `_toolResultMessageToOpenAI` + - Converts `ctx.tools` to OpenAI function tool format via `_toolsToOpenAI` +- `IMP-012`: Implement message converters (`_userMessageToOpenAI`, `_assistantMessageToOpenAI`, `_assistantMessageToolCallToOpenAI`, `_toolResultMessageToOpenAI`) — **COMPLETED** (see `src/utils.jl:321-387`) +- `IMP-013`: Implement `_messageContentToBlocks` — converts `textContent`/`imageContent` to OpenAI content blocks — **COMPLETED** (see `src/utils.jl:396-413`) +- `IMP-014`: Implement `_toolsToOpenAI` — converts `OrderedDict{String, agentTool}` to OpenAI tools array — **COMPLETED** (see `src/utils.jl:429-447`) +- `IMP-015`: Implement `beforeToolCall` hook default (returns `beforeToolCallResult(false, "N/A")`) — **COMPLETED** (see `src/utils.jl:278-284`) +- `IMP-016`: Implement `afterToolCall` hook default (returns `nothing`) — **COMPLETED** (see `src/utils.jl:310-316`) +- `IMP-017`: Implement `validateRequiredArgs(args, inputSchema)` — validates required fields in tool arguments — **COMPLETED** (see `src/utils.jl:479-497`) +- `IMP-018`: Implement `validateToolArguments(tool, prepared)` — wrapper that calls tool-specific or default hook — **COMPLETED** (see `src/utils.jl:533-546`) + +**Validation Tests**: Unit tests for converters, validation hooks, and OpenAI format output structure (SP-011..SP-016, SP-023, SP-024) + +**Traceability**: FR-001, FR-002, FR-003, FR-004, FR-005, FR-006 + +--- + +### Phase 3: Tool Registry & MCP Integration (COMPLETED) + +**Files**: `src/toolRegistry.jl` + +**Completed Tasks**: +- `IMP-020`: Implement `_extract_text_content(result)` — extracts text from MCP tool result content array — **COMPLETED** (see `src/toolRegistry.jl:19-33`) +- `IMP-021`: Implement `_wrap_mcp_tool(mcpserver, tool_def)` — wraps MCP tool as `agentTool` with `execute` calling `mcpserver("tools/call", ...)` — **COMPLETED** (see `src/toolRegistry.jl:41-106`) + - Handles MCP error responses (`haskey(response, "error")`) + - Extracts text and `isError` from result +- `IMP-022`: Implement `register_mcp_tools(tools, mcpserver)` — queries `tools/list` with pagination (`nextCursor`) — **COMPLETED** (see `src/toolRegistry.jl:118-186`) + - Handles both `result.tools` and `result.error` response shapes + - Skips already registered tools (idempotent) +- `IMP-023`: Implement `registerTool(tools, tool)` — adds tool to OrderedDict — **COMPLETED** (see `src/toolRegistry.jl:200-205`) +- `IMP-024`: Implement `listTool(tools, mcpserver)` — builtin tool that triggers runtime discovery and lists all tools — **COMPLETED** (see `src/toolRegistry.jl:235-271`) +- `IMP-025`: Implement `registerAllTools(tools, mcpserver)` — discovers existing tools + registers `listTools` — **COMPLETED** (see `src/toolRegistry.jl:286-289`) + +**Validation Tests**: Unit tests for MCP tools/list parsing, pagination, tool registration idempotency; contract tests for JSON-RPC 2.0 format (SP-009, SP-010) + +**Traceability**: FR-002, FR-004 + +--- + +### Phase 4: Agent Core & Loop (COMPLETED) + +**Files**: `src/agentCore.jl` + +**Completed Tasks**: +- `IMP-030`: Define `yiemAgent` struct with channels, callbacks, MCP server — **COMPLETED** (see `src/agentCore.jl:17-95`) +- `IMP-031`: Implement `yiemAgent` constructor — creates channels, discovers MCP tools, spawns `_agentLoop` task — **COMPLETED** (see `src/agentCore.jl:126-199`) +- `IMP-032`: Implement `_agentLoop(agent)` — background `@spawn` task managing `inputChannel`/`followUpChannel` priority dispatch — **COMPLETED** (see `src/agentCore.jl:225-364`) + - Handles shutdown signal: drains all channels, breaks loop + - On error: catches, calls `eventSink(errMsg)`, exits loop +- `IMP-033`: Implement `_processMessage(...)` — drains input, calls `llmCall`, extracts tool calls, executes, loops until no tool calls — **COMPLETED** (see `src/agentCore.jl:394-544`) + - `while true` loop: `llmCall()` → `_extractToolCalls()` → if `hasToolCalls && length > 0` execute → append results → repeat + - On `toolResultBatch.terminate == true`: build final response and break + - Catches all errors and calls `eventSink(err_msg)` +- `IMP-034`: Implement `_extractToolCalls(response)` — handles **both** formats: + - Format 1: `response["message"]["tool_calls"]` (LMStudio/vLLM style) — **COMPLETED** (see `src/agentCore.jl:671-683`) + - Format 2: `response.content` blocks with `type => "tool_calls"` or `type => "tool_call"` (OpenAI API style) — **COMPLETED** (see `src/agentCore.jl:685-719`) + - Extracts `reasoning_content`, `content` text blocks, `api`, `provider`, `model`, `usage`, `finish_reason` — **COMPLETED** (see `src/agentCore.jl:723-797`) + - Returns `(hasToolCalls, toolCallList, assistant_message)` — **COMPLETED** (see `src/agentCore.jl:799-827`) +- `IMP-035`: Implement `prepareToolCall(context, assistantMsg, toolCall, config, signal, eventSink)` — resolves tool, validates args, runs `beforeToolCall` hook, never throws — **COMPLETED** (see `src/agentCore.jl:974-1035`) +- `IMP-036`: Implement `executePreparedToolCall(prep, signal, eventSink)` — calls `tool.execute(toolCallId, args, ...)` — **COMPLETED** (see `src/agentCore.jl:1078-1100`) +- `IMP-037`: Implement `finalizeExecutedToolCall(context, msg, prep, executed, config, signal, eventSink)` — runs `afterToolCall` hook, catches errors — **COMPLETED** (see `src/agentCore.jl:1159-1198`) +- `IMP-038`: Implement `prepareToolCallArguments(tool, toolCall)` — calls `tool.prepareArguments` if present — **COMPLETED** (see `src/agentCore.jl:908-917`) +- `IMP-039`: Implement `createErrorToolResult(msg)` — builds `agentToolResult` with error content — **COMPLETED** (see `src/agentCore.jl:572-574`) +- `IMP-040`: Implement `createToolResultMessage(f)` — builds `toolResultMessage` from `finalizedOutcome` — **COMPLETED** (see `src/agentCore.jl:606-612`) + +**Note**: Sequential and parallel execution implementations continue beyond line 1207 of `src/agentCore.jl` — implementation incomplete, requires review to ensure execution methods properly call hooks and emit lifecycle events. See IMP-041. + +**Validation Tests**: Integration tests for full agent loop with mocked LLM and MCP; contract tests for response format extraction (WT-001..WT-032, SP-017, SP-018) + +**Traceability**: FR-001, FR-002, FR-003, FR-004, FR-005 + +--- + +### Phase 5: Public API (COMPLETED) + +**Files**: `src/api.jl` + +**Completed Tasks**: +- `IMP-050`: Implement `promptAgent(agent, msg::AbstractDict)` — put to `inputChannel`, returns agent for chaining — **COMPLETED** (see `src/api.jl:36-39`) +- `IMP-051`: Implement `promptAgent(agent, text::String)` — wraps text as OpenAI `Dict` and put to `inputChannel` — **COMPLETED** (see `src/api.jl:41-44`) +- `IMP-052`: Implement `takeResponse(agent)` — blocks on `outputChannel` `take!`, returns OpenAI dict response — **COMPLETED** (see `src/api.jl:66-68`) +- `IMP-053`: Implement `followUp(agent, msg::AbstractDict)` — put to `followUpChannel`, returns agent — **COMPLETED** (see `src/api.jl:94-97`) +- `IMP-054`: Implement `followUp(agent, text::String)` — wraps text as OpenAI `Dict` and put to `followUpChannel` — **COMPLETED** (see `src/api.jl:99-102`) +- `IMP-055`: Implement `stopAgent(agent)` — puts `:shutdown`, `fetch(_agentLoop)`, closes all channels — **COMPLETED** (see `src/api.jl:126-139`) + +**Validation Tests**: Integration tests for send/receive/stop lifecycle (WT-001..WT-005) + +**Traceability**: FR-001, FR-006 + +--- + +## 2. Module Structure + +``` +src/ +├── YiemAgent.jl # Parent module, includes submodules in dependency order +├── type.jl # All Julia struct types (exports 30+ types) +├── utils.jl # Formatting, validation, hooks (default implementations) +├── toolRegistry.jl # MCP tool discovery, registration, listTools builtin +├── agentCore.jl # yiemAgent struct, _agentLoop, _processMessage, extractToolCalls +└── api.jl # Public: promptAgent, takeResponse, followUp, stopAgent +``` + +**Include order** (per `src/YiemAgent.jl` lines 6-19): +1. `type.jl` (no dependencies) +2. `utils.jl` (depends on `type`) +3. `toolRegistry.jl` (depends on `type`) +4. `agentCore.jl` (depends on `type`, `utils`, `toolRegistry`) +5. `api.jl` (depends on `type`, `utils`, `agentCore`, `toolRegistry`) + +--- + +## 3. Task Dependencies + +| Task ID | Title | Depends On | Status | +|---------|-------|------------|--------| +| IMP-001..IMP-008 | Type system | — | **COMPLETED** | +| IMP-010..IMP-018 | Utils (formatMsgForLLM, converters, hooks) | IMP-001..IMP-008 | **COMPLETED** | +| IMP-020..IMP-025 | Tool registry (MCP discovery) | IMP-003, IMP-017 | **COMPLETED** | +| IMP-030..IMP-040 | Agent core (agent loop, tool call execution) | IMP-001..IMP-008, IMP-010..IMP-018, IMP-020..IMP-025 | **INCOMPLETE*** (see note) | +| IMP-050..IMP-055 | Public API | IMP-030..IMP-040 | **COMPLETED** | + +**NOTE on IMP-030..IMP-040**: The sequential and parallel execution functions (`executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls`) begin after line 1207 of `src/agentCore.jl` but the full implementation is truncated in file reads and requires review. The exported functions (`executePreparedToolCall`, `prepareToolCall`, `executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls`) are declared (`src/agentCore.jl:4-5`) but their full bodies may be incomplete. **Requires manual validation.** + +**Traceability**: IMP-001..IMP-008 → SP-001..SP-008, IMP-020..IMP-025 → SP-009, SP-010; IMP-010..IMP-018 → SP-011..SP-016, SP-023, SP-024; IMP-030..IMP-040 → SP-017, SP-018, SP-019..SP-022 + +--- + +## 4. Remaining Work (TODOs) + +| File | Line | Description | Priority | +|------|------|-------------|----------| +| `src/utils.jl` | 114 | Filter tools from `state.tools` based on user intent and tool description | High | +| `src/utils.jl` | 118 | Add system prompt context: time, session state, user preferences | Medium | +| `src/utils.jl` | 121 | Prune/reorder/conversation messages before sending to LLM | Medium | +| `src/agentCore.jl` | post-1207 | Complete and validate `executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls` implementations (currently exported but body may be incomplete) | **Critical** | +| `tests/unit/...` | — | Write unit tests for all struct types (IMP-001..IMP-008) and converters (SP-011..SP-016) | High | +| `tests/unit/...` | — | Write unit tests for tool validation hooks (SP-023, SP-024) | High | +| `tests/integration/...` | — | Write integration tests for agent loop with mocked LLM/MCP (WT-001..WT-032) | High | +| `tests/contract/...` | — | Write contract tests for response formats (SP-017, SP-018) | Medium | +| `tests/perf/...` | — | Write performance tests (NFR-101, NFR-102) | Medium | + +--- + +## 5. Build & Deployment + +### Build + +```bash +cd /home/ton/docker-apps/sommpanion/YiemAgent +julia --project=. -e 'using Pkg; Pkg.instantiate(); using Pkg; Pkg.precompile()' +``` + +### Test + +```bash +julia --project=./tests -e 'using Pkg; Pkg.instantiate()' +julia --project=./tests -e 'using Test; Test.@testset "Unit" begin include("tests/unit/run_tests.jl") end' +julia --project=./tests -e 'using Test; Test.@testset "Integration" begin include("tests/integration/run_tests.jl") end' +``` + +### CI/CD Preparation + +- Add `.github/workflows/julia.yaml` referencing `ci/julia-pipeline.yaml` steps +- Add `Project.toml` dependencies: + ```toml + [deps] + JSON = "682c8566-3430-5439-8f4e-b64832e8aaba" + DataStructures = "892a3cad-33e6-51e5-808c-f3d90072be70" + HTTP = "cd6eee30-4dd5-5329-8bad-3259ec48d0f4" + # plus: UUIDs, Dates, PrettyPrinting, Serialization, DataFrames, NATS, LibPQ + ``` + +--- + +## 6. Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Line 1207+ of `agentCore.jl` (sequential/parallel execution) is incomplete or broken | Manually review and validate `executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls` against spec SP-021 (agentLoopConfig) and trace to test `test_tool_call_execution_sequence` | +| `GeneralUtils` internal package unavailable in CI | Ensure `GeneralUtils` is installed as dependency (via `Pkg.develop` or registry) | +| NATS `mcpServer` struct is abstract — no concrete implementation | Provide at least one mock `mcpServer` for tests that returns canned JSON responses | +| Message history grows unbounded | Implement `prepareContext` pruning (TODO in code); add NFR-104 for max history tokens | diff --git a/docs/8-runbook.md b/docs/8-runbook.md new file mode 100644 index 0000000..3d49c73 --- /dev/null +++ b/docs/8-runbook.md @@ -0,0 +1,445 @@ +# Runbook: YiemAgent Agent Framework + +**Severity**: N/A (library — not a deployed service) +**Estimated Time**: N/A + +This runbook documents operational guidance for developers working with the YiemAgent agent framework. It is not an operations runbook for a production service, but a developer handbook for debugging, troubleshooting, and extending the framework. + +--- + +## 1. Agent Lifecycle + +### 1.1: Create an Agent + +**Steps**: +```julia +using YiemAgent + +# 1. Implement llmCall function +function my_llmCall(messages; eventSink=nothing) + # Call LLM API (e.g., OpenAI, Anthropic, LMStudio) + # Return dict with keys: "message", "content", "finish_reason", "api", "provider", "model", "usage" + eventSink("my_llmCall: sending $(length(messages["messages"])) messages"; publish=true) + # ... API call ... + return response_dict +end + +# 2. Implement mcpServer callable struct +struct MyMCPClient + conn::NATS.Connection # or HTTP.Client + topic::String + senderID::String +end + +function (mcp::MyMCPClient)(method::String, args...; eventSink=nothing) + # JSON-RPC 2.0 calls: + # mcp("tools/list", sessionId; eventSink=eventSink) + # mcp("tools/call", toolName, args, sessionId; eventSink=eventSink) + # ... NATS/HTTP call ... + return jsonrpc_response +end + +# 3. Create agent +mcp = MyMCPClient(nats_conn, "yiemagent:tools", "agent_1") +agent = yiemAgent( + my_llmCall; + systemPrompt="You are a wine assistant.\nYou have tools for searching wine inventory.", + tools=mcp_tools, # optional: pass pre-discovered tools + prepareContext=my_prepareContext, # optional: custom context preparation + formatMsgForLLM=my_formatMsgForLLM, # optional: custom formatting for provider + beforeToolCall=my_beforeToolCall, # optional: approval gate + afterToolCall=my_afterToolCall, # optional: result sanitization + mcpServer=mcp, + parallelToolExecute=false, + eventSink=my_eventSink, +) +``` + +> **Reference**: `2-solution_design.md:SD-001` (channel-based async), `SD-002` (background task), `SD-004` (MCP struct) + +### 1.2: Send a Message + +```julia +# Option 1: Send a string message (automatically wrapped as OpenAI dict) +promptAgent(agent, "What wines have notes of oak and vanilla?") + +# Option 2: Send a full OpenAI-format dict with images +msg = Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "What wines match these notes?"), + Dict("type" => "image_url", "image_url" => Dict("url" => "data:image/png;base64,...")) + ] +) +promptAgent(agent, msg) +``` + +### 1.3: Receive a Response + +```julia +# Blocks until agent finishes processing (no tool calls or tool calls complete) +response = takeResponse(agent) +# response is a Dict in OpenAI format: +# Dict("role" => "assistant", "content" => [Dict("type" => "text", "text" => "...")]) + +# For text-only response: +if response["content"][1]["type"] == "text" + println(response["content"][1]["text"]) +end +``` + +### 1.4: Send Follow-up While Agent is Busy + +```julia +# While the agent is processing tool calls (between promptAgent(...) and takeResponse(...)) +followUp(agent, "Also check European wines") +``` + +### 1.5: Stop the Agent + +```julia +stopAgent(agent) +# Sends :shutdown, closes all channels. Agent object is destroyed. +``` + +--- + +## 2. Troubleshooting + +### 2.1: Agent Loop Exits Silently + +**Symptoms**: +- `takeResponse(agent)` hangs indefinitely +- No output in eventSink + +**Steps**: +1. Check if `eventSink` is receiving error messages: + ```julia + global error_count = 0 + my_eventSink(msg; publish=false) = begin + if occursin("error", lowercase(msg)) || occursin("failed", lowercase(msg)) + println("ERROR EVENT: $msg") + global error_count += 1 + end + end + ``` +2. Check network connectivity to LLM API endpoint +3. Verify `formatMsgForLLM` produces valid OpenAI-format message structure (`4-specification.md:SP-011..SP-016`) + +> **Reference**: `4-specification.md` (OpenAI format specs), `3-walkthrough.md:WT-030` (LLM timeout) + +### 2.2: Tool Call Not Executing + +**Symptoms**: +- LLM returns `tool_calls` but agent returns text response immediately +- `beforeToolCall` hook is missing or incorrectly implemented + +**Steps**: +1. Verify `prepareContext` returns `agentContext` with `tools` field (not `nothing`) (`src/utils.jl:123`) +2. Check `beforeToolCall` hook is not blocking execution: + ```julia + function my_beforeToolCall(ctx, signal, eventSink) + # Returning nothing → allows execution + # Returning beforeToolCallResult(true, "blocked") → blocks + eventSink("beforeToolCall for $(ctx.toolCall.name)"; publish=true) + nothing # ← must return nothing (not beforeToolCallResult(false, "N/A")) + end + ``` +3. Verify tool exists in `context.tools[toolCall.name]` (`src/agentCore.jl:985`) +4. Check `validateToolArguments` is not throwing (catches and returns error tool result) (`src/utils.jl:533`) + +> **Reference**: `4-specification.md:SP-021` (agentLoopConfig with beforeToolCall hook), `3-walkthrough.md:WT-032` (beforeToolCall blocks) + +### 2.3: MCP Server Returns No Tools + +**Symptoms**: +- `println("[toolRegistry] Discovered 0 MCP tools."` on startup +- Agent has no tools available at all times + +**Steps**: +1. Verify `mcpServer` callable struct correctly handles `mcp("tools/list", sessionId; eventSink)` +2. Check JSON-RPC 2.0 response shape matches `4-specification.md:SP-009`: + ```json + { + "result": { + "tools": [{"name": "...", ...}], + "nextCursor": "" # empty string = no more pages + } + } + ``` +3. Verify `tools/list` error response uses `haskey(response, "error")` check (`src/toolRegistry.jl:128`) + +> **Reference**: `4-specification.md:SP-009` (tools/list format), `3-walkthrough.md:WT-020` (MCP discovery) + +### 2.4: LLM Response Format Not Parsed + +**Symptoms**: +- Agent always treats LLM response as text, never extracts tool calls +- LLM correctly returns `tool_calls` but agent ignores them + +**Steps**: +1. Verify `llmCall` returns a dict containing `message` (LMStudio/vLLM format, `4-specification.md:SP-017`) or `content` (OpenAI format, `4-specification.md:SP-018`) +2. Check for both format variations: + - Format 1: `response["message"]["tool_calls"]` (LMStudio style, see `src/agentCore.jl:671-683`) + - Format 2: `response.content[{type => "tool_calls"}]` (OpenAI style, see `src/agentCore.jl:700-705`) +3. Verify `finish_reason` field matches: `"tool_calls"` triggers tool execution, `"stop"`/`"end_turn"` breaks loop (`src/agentCore.jl:723-724`) + +> **Reference**: `4-specification.md:SP-017, SP-018`, `3-walkthrough.md:WT-011, WT-012` (LLM response handling) + +### 2.5: Tool Execution Hangs + +**Symptoms**: +- `tool.execute(...)` starts but never returns +- EventSink receives `toolExecutionStart` but never `toolExecutionEnd` + +**Steps**: +1. Implement `abortSignal` checking in tool execute function: + ```julia + function my_tool_execute(toolCallId, args, signal, eventSink) + while !signal.aborted && !all_conditions_met + # Long-running operation with periodic abort checks + sleep(0.1) + end + if signal.aborted + return agentToolResult([textContent("Cancelled")], Dict(), nothing, false) + end + return agentToolResult([textContent("Done")], Dict(), nothing, false) + end + ``` +2. External caller sets `signal.aborted = true` (if `abortSignal` is shared via closure) +3. Add timeout logic to tool execute function: + ```julia + start_time = time() + timeout_seconds = 30.0 + while time() - start_time < timeout_seconds && !signal.aborted + # operation... + sleep(0.5) + end + ``` + +> **Reference**: `4-specification.md:SP-022` (abortSignal), `3-walkthrough.md:WT-030` (LLM timeout) + +### 2.6: Message History Grows Unbounded + +**Symptoms**: +- Memory usage grows linearly with conversation length +- LLM context window exceeded for long sessions + +**Steps**: +1. Implement pruning in `prepareContext` (`src/utils.jl:112-126`): + ```julia + function my_prepareContext(state::agentState, eventSink) + # Prune old messages, keep last N turns + max_turns = 20 + recent_msgs = isempty(state.messages) ? state.messages : + state.messages[max(1, end-2*max_turns):end] + + # Optionally inject system context (time, user prefs) + preparedMessages = recent_msgs + preparedSystemPrompt = state.systemPrompt * "\nCurrent time: $(now())" + + # Filter tools (optional: keep only relevant subset) + filteredTools = state.tools # or filter by intent... + + return agentContext(preparedSystemPrompt, preparedMessages, filteredTools) + end + ``` +2. Add hard limit in `_agentLoop`: + ```julia + if length(agent._state.messages) > 200 + # Truncate oldest messages + agent._state.messages = agent._state.messages[end-100:end] + end + ``` + +> **Reference**: `3-walkthrough.md` (agent message flow), `1-requirements.md:NFR-103` (channel buffer limits) + +--- + +## 3. Extending the Framework + +### 3.1: Add a Custom LLM Provider + +**Steps**: +1. Implement `llmCall` function that accepts `FormattedMessages::Dict{String, Any}` and returns response dict +2. Ensure response dict contains at least one of: + - `message.tool_calls` format (LMStudio/vLLM) — `4-specification.md:SP-017` + - `content[{type=>"tool_calls"}]` format (OpenAI) — `4-specification.md:SP-018` +3. Response dict may optionally contain: `api`, `provider`, `model`, `usage` (`llmUsage` or dict), `finish_reason`, `error_message` (`4-specification.md:SP-017`, `SP-018.4`) + +```julia +function my_openai_llmCall(messages::Dict{String, Any}; eventSink=nothing) + # messages["messages"] contains OpenAI-format messages + # messages["temperature"] (default 0.7) + # messages["tools"] (if tools are set) + + body = Dict( + "model" => "gpt-4", + "messages" => messages["messages"], + "tools" => get(messages, "tools", Any[]) + ) + + resp = HTTP.post("https://api.openai.com/v1/chat/completions", + headers, JSON.json(body)) + json_resp = JSON.parse(String(resp.body)) + + # Extract first completion + choice = json_resp["choices"][1]["message"] + + # Build uniform response + return Dict( + "api" => "openai", + "provider" => "openai", + "model" => "gpt-4", + "message" => choice, + "usage" => json_resp["usage"], + "finish_reason" => json_resp["choices"][1]["finish_reason"] + ) +end +``` + +> **Reference**: `1-requirements.md:FR-002`, `4-specification.md:SP-017, SP-018` + +### 3.2: Add a Custom Tool + +**Steps**: +1. Define tool schema in `inputSchema` (JSON Schema format): + ```julia + schema = Dict{String, Any}( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "max_results" => Dict("type" => "integer", "default" => 5) + ), + "required" => ["query"] + ) + ``` +2. Implement `execute` function: + ```julia + tool = agentTool( + name="searchWine", + label="Search Wine Inventory", + description="Search for wines matching a query", + inputSchema=schema, + execute=(toolCallId, args, signal, eventSink) -> begin + query = args["query"] + max_results = get(args, "max_results", Dict{Any,Any}()["max_results"] => 5...) # handle default + + wines = run_search(query, max_results) + + result_text = join(["- $(w.name) (\$$(w.price))" for w in wines], "\n") + return agentToolResult( + [textContent("Found $(length(wines)) wines:\n$result_text")], + Dict("count" => length(wines)), + nothing, false # content, details, usage, terminate + ) + end, + prepareArguments = nothing, + validateRequiredArgs = nothing, # uses default validator + parallelToolExecute = false + ) + + # Register: push!(tools, tool.name => tool) + ``` +3. Optionally override validation: + ```julia + function my_validate(args) + if haskey(args, "query") && length(args["query"]) < 3 + return "Query must be at least 3 characters" + end + return nothing + end + + agentTool( + ..., + validateRequiredArgs = my_validate, # replaces default validator + ) + ``` + +> **Reference**: `1-requirements.md:FR-004`, `4-specification.md:SP-009, SP-010` + +### 3.3: Implement User Approval Gate (beforeToolCall Hook) + +**Steps**: +1. Implement `beforeToolCall` that blocks expensive/destructive operations: + ```julia + function my_approval_toolCall(ctx::beforeToolCallContext, signal::abortSignal, eventSink) + if ctx.toolCall.name in ("delete_wine", "update_price_bulk") + eventSink("Approval required for $(ctx.toolCall.name) with args $(ctx.args)") + # In production, send to UI and wait for user input + approved = ask_user_for_approval(ctx.toolCall.name, ctx.args) # blocking call + if !approved + return beforeToolCallResult(true, "User denied approval for $(ctx.toolCall.name)") + end + end + return nothing # allow execution + end + ``` + +> **Reference**: `2-solution_design.md:SD-005` (lifecycle hooks), `3-walkthrough.md:WT-032` (beforeToolCall blocks) + +### 3.4: Implement Result Sanitization (afterToolCall Hook) + +**Steps**: +1. Implement `afterToolCall` to mask sensitive data: + ```julia + function my_sanitize_toolCall(ctx::afterToolCallContext, signal::abortSignal, eventSink) + if ctx.toolCall.name == "getCustomerInfo" + # Mask sensitive fields before LLM sees result + result = ctx.result + for (i, block) in enumerate(result.content) + if block isa textContent + masked = replace(block.text, r"\d{12}" => "***-***-****") # mask SSN + result = agentToolResult( + [replace(block.text, r"\d{12}" => "***-***-****") for block in result.content], + result.details, + result.usage, + result.terminate + ) + end + end + return result + end + return nothing # pass through for other tools + end + ``` + +> **Reference**: `2-solution_design.md:SD-005` (lifecycle hooks) + +--- + +## 4. Common Error Codes + +| Error Condition | Response Code | Description | +|-----------------|---------------|-------------| +| Tool not found | `immediateOutcome("Tool not found", true)` | `context.tools[toolCall.name]` returns `nothing` | +| Missing required args | `immediateOutcome("Missing required arguments: ", true)` | `validateRequiredArgs` returns error string | +| Custom validation failed | `immediateOutcome("", true)` | Tool-specific validation hook returns error | +| beforeToolCall blocked | `immediateOutcome("Tool execution was blocked: ", true)` | `beforeToolCall` returns `beforeToolCallResult(true, reason)` | +| Operation aborted | `immediateOutcome("Operation aborted", true)` | `signal.aborted == true` after `beforeToolCall` | +| Tool execution crashed | `immediateOutcome("", true)` | `preptool.execute()` throws exception | +| MCP error | `agentToolResult(["MCP error: "], {..., "error"=>err_msg}, ...)` | Server returns `response["error"]` JSON-RPC envelope | +| MCP network error | `agentToolResult(["MCP call error: "], {..., "error"=>err_msg}, ...)` | Exception during `mcp("tools/call", ...)` | + +> **Reference**: `4-specification.md:SP-009, SP-010`, `src/agentCore.jl:989, 1013, 1019, 1033, 1097` + +--- + +## 5. Debugging Checklist + +Per ASG Framework §Pre-Code Debugging Habit: + +| Stage | Debug Question | +|-------|----------------| +| **Implementation** | If a tool is not found, does `prepareToolCall` return `immediateOutcome` with "Tool not found"? | +| **Testing** | If an LLM response has `tool_calls`, does `_extractToolCalls` return `(true, toolCallList, assistantMessageToolCall)`? | +| **Deploying** | If the agent loop exits, does `eventSink` contain the error message? | + +--- + +## 6. References + +- **API contract**: [`4-specification.md`](4-specification.md) — message types, MCP JSON-RPC format, response extraction formats +- **Solution decisions**: [`2-solution_design.md`](2-solution_design.md) — component architecture, lifecycle hooks, format-agnostic LLM responses +- **Walkthroughs**: [`3-walkthrough.md`](3-walkthrough.md) — end-to-end agent flow, error paths, hook sequences +- **Validation tests**: [`6-validation.md`](6-validation.md) — test traceability matrix mapping requirements/specs to test files -- 2.52.0