From 510cf6126c87f773c7ca078549aa6137187fd2fd Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 13 Aug 2026 05:56:08 +0700 Subject: [PATCH] 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")